diff --git a/contracts/async-vault/src/deposit.rs b/contracts/async-vault/src/deposit.rs index a2d6d685..15cc8643 100644 --- a/contracts/async-vault/src/deposit.rs +++ b/contracts/async-vault/src/deposit.rs @@ -1,57 +1,26 @@ use soroban_sdk::{panic_with_error, token::TokenClient, Address, Env}; use stellar_tokens::rwa::RWATokenClient; -use crate::epoch; use crate::error::VaultError; use crate::event::{DepositCancelled, DepositClaimed, DepositConvertedToExit, DepositRequested}; -use crate::governance; +use crate::guards; use crate::keys::DataKey; use crate::lock_policy::{self, Decision, Exit, Standing}; use crate::pricing::{Pricing, PricingScheme}; use crate::redeem; use crate::settlement; -use crate::state::{self, DepositRequest, EpochStatus}; -use crate::treasury; +use crate::state::{self, DepositRequest}; use crate::wind_down; -fn refuse_unless_allowed_in(e: &Env, holder: &Address) { - let standing = lock_policy::standing(e, holder); - if lock_policy::decide(Exit::RequestDeposit, standing, wind_down::is_active(e)) - != Decision::Proceed - { - panic_with_error!(e, lock_policy::refused_with(standing)); - } -} - -fn refuse_if_exceeds_cap(e: &Env, amount: i128) { - let Some(cap) = state::deposit_cap(e) else { - return; - }; - let current = treasury::deposited_capital(e); - let new_total = current - .checked_add(amount) - .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); - if new_total > cap { - panic_with_error!(e, VaultError::DepositCapExceeded); - } -} - pub(crate) fn request(e: &Env, holder: &Address, amount: i128) -> u64 { - wind_down::refuse_if_active(e); - governance::refuse_if_setup_open(e); + guards::not_winding_down(e); + guards::setup_finished(e); holder.require_auth(); - - if amount <= 0 { - panic_with_error!(e, VaultError::InvalidAmount); - } - refuse_unless_allowed_in(e, holder); - refuse_if_exceeds_cap(e, amount); - + guards::positive_amount(e, amount); + guards::allowed_to(e, Exit::RequestDeposit, holder); + guards::within_cap(e, amount); let epoch_id = state::current_epoch(e); - - if state::get_deposit_request(e, epoch_id, holder).is_some() { - panic_with_error!(e, VaultError::RequestOutstanding); - } + guards::no_open_deposit(e, epoch_id, holder); let mut epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); @@ -71,7 +40,10 @@ pub(crate) fn request(e: &Env, holder: &Address, amount: i128) -> u64 { }, ); state::set_epoch(e, epoch_id, &epoch); - state::set_cancellable_escrow(e, state::cancellable_escrow(e) + amount); + let escrow = state::cancellable_escrow(e) + .checked_add(amount) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); + state::set_cancellable_escrow(e, escrow); settlement::on_deposit_requested(e, epoch_id); let asset = state::get_addr(e, &DataKey::Asset); @@ -89,40 +61,21 @@ pub(crate) fn request(e: &Env, holder: &Address, amount: i128) -> u64 { pub(crate) fn claim(e: &Env, holder: &Address, epoch_id: u64) -> i128 { holder.require_auth(); - let epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); - - if epoch.status != EpochStatus::Fulfilled { - panic_with_error!(e, VaultError::EpochNotFulfilled); - } - + guards::epoch_fulfilled(e, &epoch); let mut request = state::get_deposit_request(e, epoch_id, holder) .unwrap_or_else(|| panic_with_error!(e, VaultError::RequestNotFound)); - - if request.claimed { - panic_with_error!(e, VaultError::AlreadyClaimed); - } - + guards::not_claimed(e, request.claimed); let standing = lock_policy::standing(e, holder); let decision = lock_policy::decide(Exit::ClaimDeposit, standing, wind_down::is_active(e)); - match decision { - Decision::Proceed | Decision::ConvertToExit | Decision::Surrender => {} - Decision::Refuse | Decision::Hold => { - panic_with_error!(e, lock_policy::refused_with(standing)) - } - } + guards::not_refused(e, decision, standing); let shares = Pricing::deposit_shares(e, request.amount, epoch.share_price) .unwrap_or_else(|err| panic_with_error!(e, err)); if shares == 0 { - // Pricing already released this epoch's escrow into the reserve, so a - // refund now is an extra payout rather than part of committed. It comes - // out of free reserve and must not reach money owed to a priced exit. - if treasury::free_reserve(e) < request.amount { - panic_with_error!(e, VaultError::ClaimNotCovered); - } + guards::refund_covered(e, request.amount); refund_deposit(e, holder, epoch_id, request.amount); settlement::on_deposit_claimed(e, epoch_id, 0); return 0; @@ -209,32 +162,13 @@ fn refund_deposit(e: &Env, holder: &Address, epoch_id: u64, amount: i128) { pub(crate) fn cancel(e: &Env, holder: &Address, epoch_id: u64) -> i128 { holder.require_auth(); - let epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); - - if epoch.status == EpochStatus::Fulfilled { - panic_with_error!(e, VaultError::AlreadyPriced); - } - - // Cancellation is an escape hatch, not a choice. Once the feed can price a - // sealed epoch, the price is knowable and the only way out is to take it. - // Fulfilment is closed during a wind-down, so this epoch will never take a - // price. There is nothing to decline. - if !wind_down::is_active(e) && epoch::is_priceable(e, &epoch) { - panic_with_error!(e, VaultError::PriceAvailable); - } - + guards::not_priced(e, &epoch); + guards::no_price_available(e, &epoch); let request = state::get_deposit_request(e, epoch_id, holder) .unwrap_or_else(|| panic_with_error!(e, VaultError::RequestNotFound)); - - let standing = lock_policy::standing(e, holder); - match lock_policy::decide(Exit::CancelDeposit, standing, wind_down::is_active(e)) { - Decision::Proceed => {} - Decision::Refuse | Decision::Hold | Decision::ConvertToExit | Decision::Surrender => { - panic_with_error!(e, lock_policy::refused_with(standing)) - } - } + guards::allowed_to(e, Exit::CancelDeposit, holder); state::set_cancellable_escrow( e, diff --git a/contracts/async-vault/src/epoch.rs b/contracts/async-vault/src/epoch.rs index 96ad4b03..2ed633ab 100644 --- a/contracts/async-vault/src/epoch.rs +++ b/contracts/async-vault/src/epoch.rs @@ -4,12 +4,12 @@ use strata_bindings::{OracleFeedClient, OracleState}; use crate::error::VaultError; use crate::event::{EpochClosed, EpochFulfilled}; +use crate::guards; use crate::keys::DataKey; use crate::pricing::{Pricing, PricingScheme}; use crate::settlement; use crate::state::{self, EpochInfo, EpochStatus}; use crate::timing::{FulfilmentTiming, StandardTiming}; -use crate::wind_down; /// The schedule this vault fulfils on. type Timing = StandardTiming; @@ -59,14 +59,11 @@ pub(crate) fn is_priceable(e: &Env, epoch: &EpochInfo) -> bool { } pub(crate) fn close(e: &Env) -> u64 { - wind_down::refuse_if_active(e); + guards::not_winding_down(e); let current = state::current_epoch(e); let mut epoch = state::get_epoch(e, current) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); - - if epoch.status != EpochStatus::Open { - panic_with_error!(e, VaultError::EpochNotOpen); - } + guards::epoch_open(e, &epoch); let next = current .checked_add(1) @@ -93,13 +90,10 @@ pub(crate) fn close(e: &Env) -> u64 { } pub(crate) fn fulfill(e: &Env, epoch_id: u64) -> i128 { - wind_down::refuse_if_active(e); + guards::not_winding_down(e); let mut epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); - - if let Err(refusal) = fulfilable(e, &epoch) { - panic_with_error!(e, refusal); - } + guards::fulfilable(e, &epoch); let share_price = Pricing::resolve_share_price(e).unwrap_or_else(|err| panic_with_error!(e, err)); diff --git a/contracts/async-vault/src/governance.rs b/contracts/async-vault/src/governance.rs index 78f3c649..45c44728 100644 --- a/contracts/async-vault/src/governance.rs +++ b/contracts/async-vault/src/governance.rs @@ -21,13 +21,13 @@ use strata_timelock::{queue, setup, Clock, Refusal}; use crate::error::VaultError; use crate::event::{ChangeApplied, ChangeCancelled, ChangeProposed, NoticeSet, SetupFinished}; +use crate::guards; use crate::keys::DataKey; -use crate::roles::MANAGER_ROLE; use crate::state; use crate::treasury; use crate::upgrade; use crate::wind_down; -use crate::{AsyncVault, MAX_NOTICE_SECS}; +use crate::AsyncVault; /// Seven days of five-second ledgers: how long the next admin has to accept a /// hand-over. @@ -74,7 +74,7 @@ pub(crate) struct Wiring { } impl Wiring { - fn complete(&self) -> bool { + pub(crate) fn complete(&self) -> bool { self.token_bound && self.oracle_bound && self.sole_token_manager @@ -113,29 +113,6 @@ fn refuse(e: &Env, refusal: Refusal) -> ! { } } -/// Direct admin calls are instant only before setup finishes. -pub(crate) fn require_setup_open(e: &Env) { - if !setup::is_open(e) { - panic_with_error!(e, VaultError::UseTimelock); - } -} - -/// Nothing is taken in or queued until every admin change on the kit waits for -/// the timelock. -pub(crate) fn refuse_if_setup_open(e: &Env) { - if setup::is_open(e) { - panic_with_error!(e, VaultError::SetupOpen); - } -} - -/// After setup, revoking `manager` waits like a grant; any other revoke stays -/// instant. -pub(crate) fn require_revoke_allowed(e: &Env, role: &Symbol) { - if *role == MANAGER_ROLE { - require_setup_open(e); - } -} - /// True when `contract` reads this vault's clock and its own setup has /// finished, so its admin changes wait for this timelock. pub(crate) fn bound_here(e: &Env, contract: &Address) -> bool { @@ -193,78 +170,24 @@ pub(crate) fn wiring(e: &Env) -> Wiring { } } -/// No nominee from setup may accept once changes wait, so a hand-over offered -/// during setup holds it open. pub(crate) fn finish_setup(e: &Env) { - if !setup::is_open(e) { - panic_with_error!(e, VaultError::SetupClosed); - } - if role_transfer::has_active_pending_transfer(e, &AccessControlStorageKey::PendingAdmin) { - panic_with_error!(e, VaultError::AdminTransferPending); - } - if !wiring(e).complete() { - panic_with_error!(e, VaultError::SetupIncomplete); - } + guards::setup_open(e); + guards::no_hand_over_pending(e); + guards::wiring_complete(e); setup::finish::(e).unwrap_or_else(|r| refuse(e, r)); SetupFinished {}.publish(e); } -fn check_notice(e: &Env, secs: u64) { - if secs > MAX_NOTICE_SECS { - panic_with_error!(e, VaultError::NoticeTooLong); - } - if secs > upgrade::delay(e) { - panic_with_error!(e, VaultError::NoticeAboveUpgradeDelay); - } -} - pub(crate) fn set_notice(e: &Env, secs: u64) { - check_notice(e, secs); state::set_notice(e, secs); NoticeSet { secs }.publish(e); } -fn changes_code(change: &VaultChange) -> bool { - matches!( - change, - VaultChange::Upgrade(_) | VaultChange::UpgradeDelay(_) - ) -} - -/// One code change at a time, so a standing eta is never quietly replaced. -fn code_change_pending(e: &Env) -> bool { - queue::open_ids(e).iter().any(|id| { - queue::pending::(e, id).is_some_and(|(change, _)| changes_code(&change)) - }) -} - -/// The bounds a change must meet when it is proposed and again when it -/// applies. -fn check(e: &Env, change: &VaultChange) { - match change { - VaultChange::UpgradeDelay(secs) => upgrade::check_delay(e, *secs), - VaultChange::Notice(secs) => check_notice(e, *secs), - VaultChange::WindDownDelay(secs) => wind_down::check_delay(e, *secs), - VaultChange::Oracle(feed) => { - if !bound_here(e, feed) { - panic_with_error!(e, VaultError::FeedNotBound); - } - } - VaultChange::Upgrade(_) - | VaultChange::Custodian(_) - | VaultChange::GrantRole(..) - | VaultChange::RevokeRole(..) - | VaultChange::Admin(_) => {} - } -} - pub(crate) fn propose(e: &Env, change: VaultChange) -> u32 { - wind_down::refuse_if_active(e); - refuse_if_setup_open(e); - check(e, &change); - if changes_code(&change) && code_change_pending(e) { - panic_with_error!(e, VaultError::UpgradeProposalExists); - } + guards::not_winding_down(e); + guards::setup_finished(e); + guards::change_in_bounds(e, &change); + guards::not_a_second_code_change(e, &change); let id = queue::propose::(e, &change).unwrap_or_else(|r| refuse(e, r)); ChangeProposed { id, @@ -284,7 +207,7 @@ pub(crate) fn apply(e: &Env, id: u32) { let change: VaultChange = queue::take_ready::(e, id).unwrap_or_else(|r| refuse(e, r)); // The notice or the delay may have moved since the proposal; this check decides. - check(e, &change); + guards::change_in_bounds(e, &change); let admin = access_control::get_admin(e) .unwrap_or_else(|| panic_with_error!(e, VaultError::AdminRequired)); match &change { diff --git a/contracts/async-vault/src/guards.rs b/contracts/async-vault/src/guards.rs new file mode 100644 index 00000000..71029fda --- /dev/null +++ b/contracts/async-vault/src/guards.rs @@ -0,0 +1,357 @@ +use soroban_sdk::{panic_with_error, Address, Env, Symbol}; +use stellar_access::{access_control::AccessControlStorageKey, role_transfer}; +use stellar_tokens::{ + fungible::FungibleTokenClient, + rwa::{compliance::ComplianceClient as RulesClient, RWATokenClient}, +}; +use strata_timelock::{queue, setup}; + +use crate::epoch; +use crate::error::VaultError; +use crate::governance::{self, VaultChange}; +use crate::keys::DataKey; +use crate::lock_policy::{self, Decision, Exit, Standing}; +use crate::roles::{VaultRoles, MANAGER_ROLE}; +use crate::state::{self, EpochInfo, EpochStatus}; +use crate::treasury; +use crate::upgrade::{self, MAX_UPGRADE_DELAY, MIN_UPGRADE_DELAY}; +use crate::wind_down::{ + self, WindDownInfo, WindDownStatus, MAX_WIND_DOWN_DELAY, MIN_WIND_DOWN_DELAY, +}; +use crate::MAX_NOTICE_SECS; + +pub(crate) fn roles_distinct(e: &Env, roles: &VaultRoles) { + if roles.treasury == roles.guardian + || roles.treasury == roles.governance + || roles.compliance == roles.governance + || roles.compliance == roles.treasury + { + panic_with_error!(e, VaultError::RolesNotDistinct); + } +} + +/// The first check in every entrypoint body the wind-down closes; `apply_change` +/// is closed by the timelock's own refusal instead. +pub(crate) fn not_winding_down(e: &Env) { + if wind_down::is_active(e) { + panic_with_error!(e, VaultError::WindDownActive); + } +} + +pub(crate) fn winding_down(e: &Env) { + if !wind_down::is_active(e) { + panic_with_error!(e, VaultError::WindDownNotActive); + } +} + +/// Nothing is taken in or queued until every admin change on the kit waits for +/// the timelock. +pub(crate) fn setup_finished(e: &Env) { + if setup::is_open(e) { + panic_with_error!(e, VaultError::SetupOpen); + } +} + +pub(crate) fn setup_open(e: &Env) { + if !setup::is_open(e) { + panic_with_error!(e, VaultError::SetupClosed); + } +} + +/// Direct admin calls are instant only before setup finishes. +pub(crate) fn direct_change_allowed(e: &Env) { + if !setup::is_open(e) { + panic_with_error!(e, VaultError::UseTimelock); + } +} + +/// After setup, revoking `manager` waits like a grant; any other revoke stays +/// instant. +pub(crate) fn direct_revoke_allowed(e: &Env, role: &Symbol) { + if *role == MANAGER_ROLE { + direct_change_allowed(e); + } +} + +/// Only an offer needs setup open; cancelling one (`0`) keeps the admin. +pub(crate) fn direct_hand_over_allowed(e: &Env, live_until_ledger: u32) { + if live_until_ledger != 0 { + direct_change_allowed(e); + } +} + +pub(crate) fn not_lowered_after_setup(e: &Env, new: u64, current: u64) { + if new < current { + direct_change_allowed(e); + } +} + +/// No nominee from setup may accept once changes wait, so a hand-over offered +/// during setup holds it open. +pub(crate) fn no_hand_over_pending(e: &Env) { + if role_transfer::has_active_pending_transfer(e, &AccessControlStorageKey::PendingAdmin) { + panic_with_error!(e, VaultError::AdminTransferPending); + } +} + +pub(crate) fn wiring_complete(e: &Env) { + if !governance::wiring(e).complete() { + panic_with_error!(e, VaultError::SetupIncomplete); + } +} + +pub(crate) fn non_negative_cap(e: &Env, cap: Option) { + if cap.is_some_and(|cap| cap < 0) { + panic_with_error!(e, VaultError::InvalidAmount); + } +} + +pub(crate) fn notice_in_bounds(e: &Env, secs: u64) { + if secs > MAX_NOTICE_SECS { + panic_with_error!(e, VaultError::NoticeTooLong); + } + if secs > upgrade::delay(e) { + panic_with_error!(e, VaultError::NoticeAboveUpgradeDelay); + } +} + +#[cfg_attr(feature = "short_wind_down", allow(clippy::absurd_extreme_comparisons))] +pub(crate) fn wind_down_delay_in_bounds(e: &Env, secs: u64) { + if secs < MIN_WIND_DOWN_DELAY { + panic_with_error!(e, VaultError::WindDownDelayTooShort); + } + if secs > MAX_WIND_DOWN_DELAY { + panic_with_error!(e, VaultError::WindDownDelayTooLong); + } +} + +/// An exit takes a full notice, so a delay shorter than one is not a window. +pub(crate) fn upgrade_delay_in_bounds(e: &Env, secs: u64) { + if secs < MIN_UPGRADE_DELAY { + panic_with_error!(e, VaultError::UpgradeDelayTooShort); + } + if secs > MAX_UPGRADE_DELAY { + panic_with_error!(e, VaultError::UpgradeDelayTooLong); + } + if secs < state::notice(e) { + panic_with_error!(e, VaultError::UpgradeDelayBelowNotice); + } +} + +pub(crate) fn feed_bound_here(e: &Env, feed: &Address) { + if !governance::bound_here(e, feed) { + panic_with_error!(e, VaultError::FeedNotBound); + } +} + +/// The bounds a change must meet when it is proposed and again when it +/// applies. +pub(crate) fn change_in_bounds(e: &Env, change: &VaultChange) { + match change { + VaultChange::UpgradeDelay(secs) => upgrade_delay_in_bounds(e, *secs), + VaultChange::Notice(secs) => notice_in_bounds(e, *secs), + VaultChange::WindDownDelay(secs) => wind_down_delay_in_bounds(e, *secs), + VaultChange::Oracle(feed) => feed_bound_here(e, feed), + VaultChange::Upgrade(_) + | VaultChange::Custodian(_) + | VaultChange::GrantRole(..) + | VaultChange::RevokeRole(..) + | VaultChange::Admin(_) => {} + } +} + +/// One code change at a time, so a standing eta is never quietly replaced. +pub(crate) fn not_a_second_code_change(e: &Env, change: &VaultChange) { + if changes_code(change) && code_change_pending(e) { + panic_with_error!(e, VaultError::UpgradeProposalExists); + } +} + +fn changes_code(change: &VaultChange) -> bool { + matches!( + change, + VaultChange::Upgrade(_) | VaultChange::UpgradeDelay(_) + ) +} + +fn code_change_pending(e: &Env) -> bool { + queue::open_ids(e).iter().any(|id| { + queue::pending::(e, id).is_some_and(|(change, _)| changes_code(&change)) + }) +} + +pub(crate) fn no_wind_down_proposed(e: &Env) { + if matches!( + wind_down::info(e), + Some(WindDownInfo { + status: WindDownStatus::Proposed, + .. + }) + ) { + panic_with_error!(e, VaultError::WindDownAlreadyProposed); + } +} + +pub(crate) fn wind_down_proposed(e: &Env) { + if !matches!( + wind_down::info(e), + Some(WindDownInfo { + status: WindDownStatus::Proposed, + .. + }) + ) { + panic_with_error!(e, VaultError::WindDownNotProposed); + } +} + +pub(crate) fn wind_down_delay_elapsed(e: &Env, proposal: &WindDownInfo) { + if e.ledger().timestamp() < proposal.active_at { + panic_with_error!(e, VaultError::WindDownDelayNotElapsed); + } +} + +pub(crate) fn distribution_started(e: &Env) { + if wind_down::info(e).map(|wd| wd.round).unwrap_or(0) == 0 { + panic_with_error!(e, VaultError::DistributionNotStarted); + } +} + +/// A round needs a supply to divide by and a pot that credits something. +pub(crate) fn something_to_distribute(e: &Env, value: i128) { + if value <= 0 { + panic_with_error!(e, VaultError::NothingToDistribute); + } +} + +pub(crate) fn has_entitlement(e: &Env, surrendered: i128, assets: i128) { + if surrendered == 0 && assets == 0 { + panic_with_error!(e, VaultError::NoEntitlement); + } +} + +pub(crate) fn positive_amount(e: &Env, amount: i128) { + if amount <= 0 { + panic_with_error!(e, VaultError::InvalidAmount); + } +} + +pub(crate) fn within_cap(e: &Env, amount: i128) { + let Some(cap) = state::deposit_cap(e) else { + return; + }; + let new_total = treasury::deposited_capital(e) + .checked_add(amount) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); + if new_total > cap { + panic_with_error!(e, VaultError::DepositCapExceeded); + } +} + +pub(crate) fn within_free_reserve(e: &Env, assets: i128) { + if assets > treasury::free_reserve(e) { + panic_with_error!(e, VaultError::ReserveCommittedToExits); + } +} + +pub(crate) fn no_open_deposit(e: &Env, epoch_id: u64, holder: &Address) { + if state::get_deposit_request(e, epoch_id, holder).is_some() { + panic_with_error!(e, VaultError::RequestOutstanding); + } +} + +pub(crate) fn no_open_redeem(e: &Env, epoch_id: u64, holder: &Address) { + if state::get_redeem_request(e, epoch_id, holder).is_some() { + panic_with_error!(e, VaultError::RequestOutstanding); + } +} + +pub(crate) fn not_claimed(e: &Env, claimed: bool) { + if claimed { + panic_with_error!(e, VaultError::AlreadyClaimed); + } +} + +/// A request within the balance but short of the free shares needs frozen +/// ones; beyond the balance entirely, the token's own check applies instead. +pub(crate) fn needs_no_frozen_shares(e: &Env, holder: &Address, shares: i128) { + if shares > lock_policy::free_shares(e, holder) { + let held = + FungibleTokenClient::new(e, &state::get_addr(e, &DataKey::ShareToken)).balance(holder); + if shares <= held { + panic_with_error!(e, VaultError::Frozen); + } + } +} + +/// A refund after pricing comes out of free reserve, never out of money owed to exits. +pub(crate) fn refund_covered(e: &Env, amount: i128) { + if treasury::free_reserve(e) < amount { + panic_with_error!(e, VaultError::ClaimNotCovered); + } +} + +/// Against liquid reserve, not the raw balance: escrow a depositor can still +/// cancel is not available to pay an exit. +pub(crate) fn payout_covered(e: &Env, assets: i128) { + if treasury::liquid_reserve(e) < assets { + panic_with_error!(e, VaultError::ClaimNotCovered); + } +} + +/// The holder may hold shares and the compliance rules let the vault send them. +pub(crate) fn eligible_to_receive(e: &Env, holder: &Address, shares: i128) { + let vault = e.current_contract_address(); + let token = state::get_addr(e, &DataKey::ShareToken); + let share = RWATokenClient::new(e, &token); + if lock_policy::standing(e, holder) != Standing::Listed + || !RulesClient::new(e, &share.compliance()).can_transfer(&vault, holder, &shares, &token) + { + panic_with_error!(e, VaultError::HolderNotEligible); + } +} + +pub(crate) fn epoch_open(e: &Env, epoch: &EpochInfo) { + if epoch.status != EpochStatus::Open { + panic_with_error!(e, VaultError::EpochNotOpen); + } +} + +pub(crate) fn epoch_fulfilled(e: &Env, epoch: &EpochInfo) { + if epoch.status != EpochStatus::Fulfilled { + panic_with_error!(e, VaultError::EpochNotFulfilled); + } +} + +pub(crate) fn not_priced(e: &Env, epoch: &EpochInfo) { + if epoch.status == EpochStatus::Fulfilled { + panic_with_error!(e, VaultError::AlreadyPriced); + } +} + +/// A readable price closes cancellation, except in a wind-down, which prices nothing. +pub(crate) fn no_price_available(e: &Env, epoch: &EpochInfo) { + if !wind_down::is_active(e) && epoch::is_priceable(e, epoch) { + panic_with_error!(e, VaultError::PriceAvailable); + } +} + +pub(crate) fn fulfilable(e: &Env, epoch: &EpochInfo) { + if let Err(refusal) = epoch::fulfilable(e, epoch) { + panic_with_error!(e, refusal); + } +} + +/// The lock policy lets the holder's `exit` go ahead as asked. +pub(crate) fn allowed_to(e: &Env, exit: Exit, holder: &Address) { + let standing = lock_policy::standing(e, holder); + if lock_policy::decide(exit, standing, wind_down::is_active(e)) != Decision::Proceed { + panic_with_error!(e, lock_policy::refused_with(standing)); + } +} + +/// The lock policy neither refuses nor holds the exit. +pub(crate) fn not_refused(e: &Env, decision: Decision, standing: Standing) { + if matches!(decision, Decision::Refuse | Decision::Hold) { + panic_with_error!(e, lock_policy::refused_with(standing)); + } +} diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 895e84c0..700a809e 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -5,6 +5,7 @@ mod epoch; mod error; mod event; mod governance; +mod guards; mod health; mod keys; mod lock_policy; @@ -81,13 +82,7 @@ impl AsyncVault { // on the NAV oracle, the vault checks pairwise distinctness at construction // time to prevent role misconfiguration across the kit ecosystem. Neither // compliance nor attester is granted a direct role on the vault contract itself. - if roles.treasury == roles.guardian - || roles.treasury == roles.governance - || roles.compliance == roles.governance - || roles.compliance == roles.treasury - { - panic_with_error!(e, VaultError::RolesNotDistinct); - } + guards::roles_distinct(e, &roles); access_control::set_admin(e, &roles.governance); access_control::grant_role_no_auth(e, &roles.manager, &MANAGER_ROLE, &roles.governance); @@ -172,7 +167,7 @@ impl AsyncVault { let client = FungibleTokenClient::new(e, &share_token); let total_supply = client.total_supply(); let vault_escrow = client.balance(&e.current_contract_address()); - let circulating = total_supply.saturating_sub(vault_escrow); + let circulating = state::reduce(e, total_supply, vault_escrow); circulating .checked_add(state::pending_mint_shares(e)) .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)) @@ -185,11 +180,7 @@ impl AsyncVault { #[only_admin] pub fn set_deposit_cap(e: &Env, cap: Option, caller: Address) { caller.require_auth(); - if let Some(c) = cap { - if c < 0 { - panic_with_error!(e, VaultError::InvalidAmount); - } - } + guards::non_negative_cap(e, cap); let old_cap = state::deposit_cap(e); state::set_deposit_cap(e, cap); DepositCapUpdated { @@ -204,7 +195,7 @@ impl AsyncVault { #[only_admin] pub fn set_custodian(e: &Env, custodian: Address, caller: Address) { caller.require_auth(); - governance::require_setup_open(e); + guards::direct_change_allowed(e); treasury::set_custodian(e, &custodian); } @@ -213,10 +204,8 @@ impl AsyncVault { #[only_admin] pub fn set_wind_down_delay(e: &Env, secs: u64, caller: Address) { caller.require_auth(); - wind_down::check_delay(e, secs); - if secs < wind_down::delay(e) { - governance::require_setup_open(e); - } + guards::wind_down_delay_in_bounds(e, secs); + guards::not_lowered_after_setup(e, secs, wind_down::delay(e)); wind_down::set_delay(e, secs); } @@ -280,9 +269,8 @@ impl AsyncVault { #[only_admin] pub fn set_notice(e: &Env, secs: u64, caller: Address) { caller.require_auth(); - if secs < state::notice(e) { - governance::require_setup_open(e); - } + guards::not_lowered_after_setup(e, secs, state::notice(e)); + guards::notice_in_bounds(e, secs); governance::set_notice(e, secs); } @@ -422,7 +410,7 @@ impl AsyncVault { impl AccessControl for AsyncVault { /// Instant during setup; after it, propose the grant through the timelock. fn grant_role(e: &Env, account: Address, role: Symbol, caller: Address) { - governance::require_setup_open(e); + guards::direct_change_allowed(e); access_control::grant_role(e, &account, &role, &caller); } @@ -430,16 +418,14 @@ impl AccessControl for AsyncVault { /// away moves no value. After setup, revoking `manager` waits like a grant, /// because without it no epoch closes and no exit is priced. fn revoke_role(e: &Env, account: Address, role: Symbol, caller: Address) { - governance::require_revoke_allowed(e, &role); + guards::direct_revoke_allowed(e, &role); access_control::revoke_role(e, &account, &role, &caller); } /// Cancelling a pending hand-over (`live_until_ledger == 0`) keeps the /// current admin, so it stays instant. fn transfer_admin_role(e: &Env, new_admin: Address, live_until_ledger: u32) { - if live_until_ledger != 0 { - governance::require_setup_open(e); - } + guards::direct_hand_over_allowed(e, live_until_ledger); access_control::transfer_admin_role(e, &new_admin, live_until_ledger); } diff --git a/contracts/async-vault/src/redeem.rs b/contracts/async-vault/src/redeem.rs index 005638cc..1ccb3ed5 100644 --- a/contracts/async-vault/src/redeem.rs +++ b/contracts/async-vault/src/redeem.rs @@ -1,50 +1,24 @@ use soroban_sdk::{panic_with_error, token::TokenClient, Address, Env}; -use stellar_tokens::{ - fungible::FungibleTokenClient, - rwa::{compliance::ComplianceClient as RulesClient, RWATokenClient}, -}; +use stellar_tokens::rwa::RWATokenClient; -use crate::epoch; use crate::error::VaultError; use crate::event::{RedeemCancelled, RedeemClaimed, RedeemRequested, RedemptionSurrendered}; +use crate::guards; use crate::keys::DataKey; -use crate::lock_policy::{self, Decision, Exit, Standing}; +use crate::lock_policy::{self, Decision, Exit}; use crate::pricing::{Pricing, PricingScheme}; use crate::settlement; -use crate::state::{self, EpochStatus, RedeemRequest}; -use crate::treasury; +use crate::state::{self, RedeemRequest}; use crate::wind_down; pub(crate) fn request(e: &Env, holder: &Address, shares: i128) -> u64 { - wind_down::refuse_if_active(e); + guards::not_winding_down(e); holder.require_auth(); - - if shares <= 0 { - panic_with_error!(e, VaultError::InvalidAmount); - } - - let standing = lock_policy::standing(e, holder); - match lock_policy::decide(Exit::RequestRedeem, standing, wind_down::is_active(e)) { - Decision::Proceed => {} - Decision::Refuse | Decision::Hold | Decision::ConvertToExit | Decision::Surrender => { - panic_with_error!(e, lock_policy::refused_with(standing)) - } - } - // A request within the balance but short of the free shares needs frozen - // ones; beyond the balance entirely, the token's own check applies instead. - if shares > lock_policy::free_shares(e, holder) { - let held = - FungibleTokenClient::new(e, &state::get_addr(e, &DataKey::ShareToken)).balance(holder); - if shares <= held { - panic_with_error!(e, VaultError::Frozen); - } - } - + guards::positive_amount(e, shares); + guards::allowed_to(e, Exit::RequestRedeem, holder); + guards::needs_no_frozen_shares(e, holder, shares); let epoch_id = state::current_epoch(e); - - if state::get_redeem_request(e, epoch_id, holder).is_some() { - panic_with_error!(e, VaultError::RequestOutstanding); - } + guards::no_open_redeem(e, epoch_id, holder); let mut epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); @@ -82,28 +56,13 @@ pub(crate) fn request(e: &Env, holder: &Address, shares: i128) -> u64 { pub(crate) fn claim(e: &Env, holder: &Address, epoch_id: u64) -> i128 { holder.require_auth(); - let epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); - - if epoch.status != EpochStatus::Fulfilled { - panic_with_error!(e, VaultError::EpochNotFulfilled); - } - + guards::epoch_fulfilled(e, &epoch); let mut request = state::get_redeem_request(e, epoch_id, holder) .unwrap_or_else(|| panic_with_error!(e, VaultError::RequestNotFound)); - - if request.claimed { - panic_with_error!(e, VaultError::AlreadyClaimed); - } - - let standing = lock_policy::standing(e, holder); - match lock_policy::decide(Exit::ClaimRedeem, standing, wind_down::is_active(e)) { - Decision::Proceed => {} - Decision::Refuse | Decision::Hold | Decision::ConvertToExit | Decision::Surrender => { - panic_with_error!(e, lock_policy::refused_with(standing)) - } - } + guards::not_claimed(e, request.claimed); + guards::allowed_to(e, Exit::ClaimRedeem, holder); let assets = Pricing::redeem_assets(e, request.shares, epoch.share_price) .unwrap_or_else(|err| panic_with_error!(e, err)); @@ -120,11 +79,7 @@ pub(crate) fn claim(e: &Env, holder: &Address, epoch_id: u64) -> i128 { return 0; } - // Against liquid reserve, not the raw balance: escrow a depositor can still - // cancel is not available to pay an exit. - if treasury::liquid_reserve(e) < assets { - panic_with_error!(e, VaultError::ClaimNotCovered); - } + guards::payout_covered(e, assets); let vault = e.current_contract_address(); let asset = state::get_addr(e, &DataKey::Asset); @@ -205,42 +160,25 @@ fn return_shares(e: &Env, holder: &Address, epoch_id: u64, shares: i128) { /// Checks the receiver, then moves as manager, so the vault's listing and token pause never apply. fn deliver(e: &Env, holder: &Address, shares: i128) { + guards::eligible_to_receive(e, holder, shares); let vault = e.current_contract_address(); let token = state::get_addr(e, &DataKey::ShareToken); - let share = RWATokenClient::new(e, &token); - if lock_policy::standing(e, holder) != Standing::Listed - || !RulesClient::new(e, &share.compliance()).can_transfer(&vault, holder, &shares, &token) - { - panic_with_error!(e, VaultError::HolderNotEligible); - } - share.forced_transfer(&vault, holder, &shares, &vault); + RWATokenClient::new(e, &token).forced_transfer(&vault, holder, &shares, &vault); } pub(crate) fn cancel(e: &Env, holder: &Address, epoch_id: u64) -> i128 { holder.require_auth(); - let epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); - - if epoch.status == EpochStatus::Fulfilled { - panic_with_error!(e, VaultError::AlreadyPriced); - } - - let winding_down = wind_down::is_active(e); - - // Cancellation is an escape hatch, not a choice. Once the feed can price a - // sealed epoch, the price is knowable and the only way out is to take it. - // Fulfilment is closed during a wind-down, so this epoch will never take a - // price. There is nothing to decline. - if !winding_down && epoch::is_priceable(e, &epoch) { - panic_with_error!(e, VaultError::PriceAvailable); - } - + guards::not_priced(e, &epoch); + guards::no_price_available(e, &epoch); let request = state::get_redeem_request(e, epoch_id, holder) .unwrap_or_else(|| panic_with_error!(e, VaultError::RequestNotFound)); - let standing = lock_policy::standing(e, holder); - match lock_policy::decide(Exit::CancelRedeem, standing, winding_down) { + let decision = lock_policy::decide(Exit::CancelRedeem, standing, wind_down::is_active(e)); + guards::not_refused(e, decision, standing); + + match decision { Decision::Proceed => return_shares(e, holder, epoch_id, request.shares), Decision::Surrender => surrender_escrow(e, holder, epoch_id, request.shares), Decision::Refuse | Decision::Hold | Decision::ConvertToExit => { diff --git a/contracts/async-vault/src/settlement.rs b/contracts/async-vault/src/settlement.rs index 24ab22cb..9db3d948 100644 --- a/contracts/async-vault/src/settlement.rs +++ b/contracts/async-vault/src/settlement.rs @@ -33,14 +33,25 @@ fn save(e: &Env, epoch: u64, s: &EpochSettlement) { strata_storage::set_persistent(e, &DataKey::Settlement(epoch), s); } +fn open_one(e: &Env, open: u32) -> u32 { + open.checked_add(1) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)) +} + fn close_one(e: &Env, open: u32) -> u32 { open.checked_sub(1) .unwrap_or_else(|| panic_with_error!(e, VaultError::AccountingMismatch)) } +fn add(e: &Env, total: i128, amount: i128) -> i128 { + total + .checked_add(amount) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)) +} + pub(crate) fn on_deposit_requested(e: &Env, epoch: u64) { let mut s = load(e, epoch); - s.deposits_open += 1; + s.deposits_open = open_one(e, s.deposits_open); save(e, epoch, &s); } @@ -52,7 +63,7 @@ pub(crate) fn on_deposit_cancelled(e: &Env, epoch: u64) { pub(crate) fn on_redeem_requested(e: &Env, epoch: u64) { let mut s = load(e, epoch); - s.redeems_open += 1; + s.redeems_open = open_one(e, s.redeems_open); save(e, epoch, &s); } @@ -75,7 +86,7 @@ pub(crate) fn on_priced(e: &Env, epoch: u64, shares_promised: i128, assets_promi pub(crate) fn on_deposit_claimed(e: &Env, epoch: u64, shares: i128) { let mut s = load(e, epoch); s.deposits_open = close_one(e, s.deposits_open); - s.shares_settled += shares; + s.shares_settled = add(e, s.shares_settled, shares); reconcile_if_settled(e, epoch, &mut s); save(e, epoch, &s); } @@ -84,7 +95,7 @@ pub(crate) fn on_deposit_claimed(e: &Env, epoch: u64, shares: i128) { pub(crate) fn on_redeem_claimed(e: &Env, epoch: u64, assets: i128) { let mut s = load(e, epoch); s.redeems_open = close_one(e, s.redeems_open); - s.assets_settled += assets; + s.assets_settled = add(e, s.assets_settled, assets); reconcile_if_settled(e, epoch, &mut s); save(e, epoch, &s); } diff --git a/contracts/async-vault/src/test/deposit.rs b/contracts/async-vault/src/test/deposit.rs index af1351cf..e9123955 100644 --- a/contracts/async-vault/src/test/deposit.rs +++ b/contracts/async-vault/src/test/deposit.rs @@ -230,3 +230,17 @@ fn deposits_after_fulfilling_land_in_the_new_epoch() { ); assert_eq!(f.balance(&f.vault.address), 700); } + +#[test] +fn escrow_that_would_overflow_is_refused_by_name() { + let f = setup(); + let investor = f.investor(1); + f.e.as_contract(&f.vault.address, || { + crate::state::set_cancellable_escrow(&f.e, i128::MAX) + }); + + refused( + f.vault.try_request_deposit(&investor, &1), + VaultError::AmountTooLarge, + ); +} diff --git a/contracts/async-vault/src/test/guards.rs b/contracts/async-vault/src/test/guards.rs new file mode 100644 index 00000000..9cfabafa --- /dev/null +++ b/contracts/async-vault/src/test/guards.rs @@ -0,0 +1,706 @@ +//! Each guard passes while its condition holds and refuses with its error once +//! it does not. + +use soroban_sdk::InvokeError; +use stellar_tokens::rwa::compliance::ComplianceHook; + +use super::share_returns::RefusesEveryTransfer; +use super::wind_down::wind_down_now; +use super::*; +use crate::guards; +use crate::lock_policy::{Decision, Exit, Standing}; +use crate::{EpochInfo, WindDownInfo}; + +/// Runs `guard` inside the vault, as an entrypoint would. +fn run(f: &Fixture, guard: impl FnOnce()) -> Result<(), Result> { + f.e.try_as_contract::<(), soroban_sdk::Error>(&f.vault.address, guard) +} + +fn holds(f: &Fixture, guard: impl FnOnce()) { + assert_eq!(run(f, guard), Ok(())); +} + +fn refuses(f: &Fixture, error: VaultError, guard: impl FnOnce()) { + assert_eq!(run(f, guard), Err(Ok(error.into()))); +} + +fn epoch(f: &Fixture, id: u64) -> EpochInfo { + f.vault.get_epoch(&id).unwrap() +} + +#[test] +fn roles_distinct_refuses_one_account_in_two_seats_kept_apart() { + let f = setup(); + let roles = distinct_roles(&f.e); + holds(&f, || guards::roles_distinct(&f.e, &roles)); + + for shared in [ + VaultRoles { + guardian: roles.treasury.clone(), + ..roles.clone() + }, + VaultRoles { + governance: roles.treasury.clone(), + ..roles.clone() + }, + VaultRoles { + governance: roles.compliance.clone(), + ..roles.clone() + }, + VaultRoles { + treasury: roles.compliance.clone(), + ..roles.clone() + }, + ] { + refuses(&f, VaultError::RolesNotDistinct, || { + guards::roles_distinct(&f.e, &shared) + }); + } +} + +#[test] +fn not_winding_down_refuses_once_the_wind_down_is_active() { + let f = setup(); + holds(&f, || guards::not_winding_down(&f.e)); + + f.vault.propose_wind_down(&f.admin); + holds(&f, || guards::not_winding_down(&f.e)); + + f.advance(MIN_WIND_DOWN_DELAY); + f.vault.activate_wind_down(); + refuses(&f, VaultError::WindDownActive, || { + guards::not_winding_down(&f.e) + }); +} + +#[test] +fn winding_down_refuses_until_the_wind_down_is_active() { + let f = setup(); + refuses(&f, VaultError::WindDownNotActive, || { + guards::winding_down(&f.e) + }); + + f.vault.propose_wind_down(&f.admin); + refuses(&f, VaultError::WindDownNotActive, || { + guards::winding_down(&f.e) + }); + + f.advance(MIN_WIND_DOWN_DELAY); + f.vault.activate_wind_down(); + holds(&f, || guards::winding_down(&f.e)); +} + +#[test] +fn setup_finished_refuses_while_setup_is_open() { + let open = setup_unfinished(); + refuses(&open, VaultError::SetupOpen, || { + guards::setup_finished(&open.e) + }); + + let finished = setup(); + holds(&finished, || guards::setup_finished(&finished.e)); +} + +#[test] +fn setup_open_refuses_once_setup_has_finished() { + let open = setup_unfinished(); + holds(&open, || guards::setup_open(&open.e)); + + let finished = setup(); + refuses(&finished, VaultError::SetupClosed, || { + guards::setup_open(&finished.e) + }); +} + +#[test] +fn direct_change_allowed_refuses_after_setup() { + let open = setup_unfinished(); + holds(&open, || guards::direct_change_allowed(&open.e)); + + let finished = setup(); + refuses(&finished, VaultError::UseTimelock, || { + guards::direct_change_allowed(&finished.e) + }); +} + +#[test] +fn direct_revoke_allowed_refuses_only_the_manager_role_after_setup() { + let open = setup_unfinished(); + holds(&open, || { + guards::direct_revoke_allowed(&open.e, &symbol_short!("manager")) + }); + + let f = setup(); + holds(&f, || { + guards::direct_revoke_allowed(&f.e, &symbol_short!("guardian")) + }); + holds(&f, || { + guards::direct_revoke_allowed(&f.e, &symbol_short!("treasury")) + }); + refuses(&f, VaultError::UseTimelock, || { + guards::direct_revoke_allowed(&f.e, &symbol_short!("manager")) + }); +} + +#[test] +fn direct_hand_over_allowed_refuses_only_an_offer_after_setup() { + let open = setup_unfinished(); + holds(&open, || guards::direct_hand_over_allowed(&open.e, 100)); + + let f = setup(); + holds(&f, || guards::direct_hand_over_allowed(&f.e, 0)); + refuses(&f, VaultError::UseTimelock, || { + guards::direct_hand_over_allowed(&f.e, 100) + }); +} + +#[test] +fn not_lowered_after_setup_refuses_only_a_lower_value_after_setup() { + let open = setup_unfinished(); + holds(&open, || guards::not_lowered_after_setup(&open.e, 1, 2)); + + let f = setup(); + holds(&f, || guards::not_lowered_after_setup(&f.e, 2, 2)); + holds(&f, || guards::not_lowered_after_setup(&f.e, 3, 2)); + refuses(&f, VaultError::UseTimelock, || { + guards::not_lowered_after_setup(&f.e, 1, 2) + }); +} + +#[test] +fn no_hand_over_pending_refuses_while_an_offer_is_open() { + let f = setup_unfinished(); + holds(&f, || guards::no_hand_over_pending(&f.e)); + + let next = Address::generate(&f.e); + f.vault + .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)); + refuses(&f, VaultError::AdminTransferPending, || { + guards::no_hand_over_pending(&f.e) + }); + + f.vault.transfer_admin_role(&next, &0); + holds(&f, || guards::no_hand_over_pending(&f.e)); +} + +#[test] +fn wiring_complete_refuses_a_miswired_vault() { + let wired = setup_unfinished(); + holds(&wired, || guards::wiring_complete(&wired.e)); + + let unbound = miswired(Miswire::TokenUnbound); + refuses(&unbound, VaultError::SetupIncomplete, || { + guards::wiring_complete(&unbound.e) + }); +} + +#[test] +fn non_negative_cap_refuses_a_negative_cap() { + let f = setup(); + holds(&f, || guards::non_negative_cap(&f.e, None)); + holds(&f, || guards::non_negative_cap(&f.e, Some(0))); + refuses(&f, VaultError::InvalidAmount, || { + guards::non_negative_cap(&f.e, Some(-1)) + }); +} + +#[test] +fn notice_in_bounds_refuses_a_notice_too_long_or_above_the_upgrade_delay() { + let f = setup(); + holds(&f, || guards::notice_in_bounds(&f.e, 0)); + holds(&f, || guards::notice_in_bounds(&f.e, MIN_UPGRADE_DELAY)); + refuses(&f, VaultError::NoticeAboveUpgradeDelay, || { + guards::notice_in_bounds(&f.e, MIN_UPGRADE_DELAY + 1) + }); + refuses(&f, VaultError::NoticeTooLong, || { + guards::notice_in_bounds(&f.e, MAX_NOTICE_SECS + 1) + }); +} + +#[test] +fn wind_down_delay_in_bounds_refuses_a_delay_outside_the_bounds() { + let f = setup(); + holds(&f, || { + guards::wind_down_delay_in_bounds(&f.e, MIN_WIND_DOWN_DELAY) + }); + holds(&f, || { + guards::wind_down_delay_in_bounds(&f.e, MAX_WIND_DOWN_DELAY) + }); + refuses(&f, VaultError::WindDownDelayTooShort, || { + guards::wind_down_delay_in_bounds(&f.e, MIN_WIND_DOWN_DELAY - 1) + }); + refuses(&f, VaultError::WindDownDelayTooLong, || { + guards::wind_down_delay_in_bounds(&f.e, MAX_WIND_DOWN_DELAY + 1) + }); +} + +#[test] +fn upgrade_delay_in_bounds_refuses_a_delay_outside_the_bounds_or_below_the_notice() { + let f = setup(); + holds(&f, || { + guards::upgrade_delay_in_bounds(&f.e, MIN_UPGRADE_DELAY) + }); + holds(&f, || { + guards::upgrade_delay_in_bounds(&f.e, MAX_UPGRADE_DELAY) + }); + refuses(&f, VaultError::UpgradeDelayTooShort, || { + guards::upgrade_delay_in_bounds(&f.e, MIN_UPGRADE_DELAY - 1) + }); + refuses(&f, VaultError::UpgradeDelayTooLong, || { + guards::upgrade_delay_in_bounds(&f.e, MAX_UPGRADE_DELAY + 1) + }); + + f.e.as_contract(&f.vault.address, || { + crate::state::set_notice(&f.e, 10 * DAY_DURATION) + }); + refuses(&f, VaultError::UpgradeDelayBelowNotice, || { + guards::upgrade_delay_in_bounds(&f.e, 8 * DAY_DURATION) + }); +} + +#[test] +fn feed_bound_here_refuses_a_feed_not_bound_to_this_vault() { + let f = setup(); + holds(&f, || guards::feed_bound_here(&f.e, &f.oracle.address)); + + let unbound = f.e.register( + NavOracleContract, + ( + f.admin.clone(), + f.attester.clone(), + f.guardian.clone(), + open_feed(), + ), + ); + refuses(&f, VaultError::FeedNotBound, || { + guards::feed_bound_here(&f.e, &unbound) + }); +} + +#[test] +fn change_in_bounds_checks_the_bounds_of_each_change() { + let f = setup(); + let stranger = Address::generate(&f.e); + for change in [ + VaultChange::Upgrade(BytesN::from_array(&f.e, &[1; 32])), + VaultChange::UpgradeDelay(MIN_UPGRADE_DELAY), + VaultChange::Custodian(stranger.clone()), + VaultChange::Notice(0), + VaultChange::WindDownDelay(MIN_WIND_DOWN_DELAY), + VaultChange::GrantRole(symbol_short!("guardian"), stranger.clone()), + VaultChange::RevokeRole(symbol_short!("guardian"), stranger.clone()), + VaultChange::Admin(stranger.clone()), + VaultChange::Oracle(f.oracle.address.clone()), + ] { + holds(&f, || guards::change_in_bounds(&f.e, &change)); + } + + for (change, error) in [ + ( + VaultChange::UpgradeDelay(MIN_UPGRADE_DELAY - 1), + VaultError::UpgradeDelayTooShort, + ), + ( + VaultChange::Notice(MAX_NOTICE_SECS + 1), + VaultError::NoticeTooLong, + ), + ( + VaultChange::WindDownDelay(MAX_WIND_DOWN_DELAY + 1), + VaultError::WindDownDelayTooLong, + ), + ( + VaultChange::Oracle(stranger.clone()), + VaultError::FeedNotBound, + ), + ] { + refuses(&f, error, || guards::change_in_bounds(&f.e, &change)); + } +} + +#[test] +fn not_a_second_code_change_refuses_a_code_change_while_one_waits() { + let f = setup(); + let upgrade = VaultChange::Upgrade(BytesN::from_array(&f.e, &[1; 32])); + holds(&f, || guards::not_a_second_code_change(&f.e, &upgrade)); + + f.vault + .propose_change(&VaultChange::UpgradeDelay(MIN_UPGRADE_DELAY), &f.admin); + refuses(&f, VaultError::UpgradeProposalExists, || { + guards::not_a_second_code_change(&f.e, &upgrade) + }); + refuses(&f, VaultError::UpgradeProposalExists, || { + guards::not_a_second_code_change(&f.e, &VaultChange::UpgradeDelay(MAX_UPGRADE_DELAY)) + }); + holds(&f, || { + guards::not_a_second_code_change(&f.e, &VaultChange::Notice(0)) + }); +} + +#[test] +fn no_wind_down_proposed_refuses_a_standing_proposal() { + let f = setup(); + holds(&f, || guards::no_wind_down_proposed(&f.e)); + + f.vault.propose_wind_down(&f.admin); + refuses(&f, VaultError::WindDownAlreadyProposed, || { + guards::no_wind_down_proposed(&f.e) + }); +} + +#[test] +fn wind_down_proposed_refuses_without_a_standing_proposal() { + let f = setup(); + refuses(&f, VaultError::WindDownNotProposed, || { + guards::wind_down_proposed(&f.e) + }); + + f.vault.propose_wind_down(&f.admin); + holds(&f, || guards::wind_down_proposed(&f.e)); + + f.advance(MIN_WIND_DOWN_DELAY); + f.vault.activate_wind_down(); + refuses(&f, VaultError::WindDownNotProposed, || { + guards::wind_down_proposed(&f.e) + }); +} + +#[test] +fn wind_down_delay_elapsed_refuses_before_the_activation_time() { + let f = setup(); + let now = f.e.ledger().timestamp(); + let proposal = |active_at| WindDownInfo { + status: WindDownStatus::Proposed, + active_at, + round: 0, + }; + + holds(&f, || guards::wind_down_delay_elapsed(&f.e, &proposal(now))); + refuses(&f, VaultError::WindDownDelayNotElapsed, || { + guards::wind_down_delay_elapsed(&f.e, &proposal(now + 1)) + }); +} + +#[test] +fn distribution_started_refuses_before_the_first_round() { + let f = setup(); + let _holder = f.holder(500); + refuses(&f, VaultError::DistributionNotStarted, || { + guards::distribution_started(&f.e) + }); + + wind_down_now(&f); + refuses(&f, VaultError::DistributionNotStarted, || { + guards::distribution_started(&f.e) + }); + + f.vault.finalize_wind_down_round(); + holds(&f, || guards::distribution_started(&f.e)); +} + +#[test] +fn something_to_distribute_refuses_zero_and_below() { + let f = setup(); + holds(&f, || guards::something_to_distribute(&f.e, 1)); + refuses(&f, VaultError::NothingToDistribute, || { + guards::something_to_distribute(&f.e, 0) + }); + refuses(&f, VaultError::NothingToDistribute, || { + guards::something_to_distribute(&f.e, -1) + }); +} + +#[test] +fn has_entitlement_refuses_when_nothing_is_surrendered_or_owed() { + let f = setup(); + holds(&f, || guards::has_entitlement(&f.e, 1, 0)); + holds(&f, || guards::has_entitlement(&f.e, 0, 1)); + refuses(&f, VaultError::NoEntitlement, || { + guards::has_entitlement(&f.e, 0, 0) + }); +} + +#[test] +fn positive_amount_refuses_zero_and_below() { + let f = setup(); + holds(&f, || guards::positive_amount(&f.e, 1)); + refuses(&f, VaultError::InvalidAmount, || { + guards::positive_amount(&f.e, 0) + }); + refuses(&f, VaultError::InvalidAmount, || { + guards::positive_amount(&f.e, -1) + }); +} + +#[test] +fn within_cap_refuses_a_deposit_past_the_cap() { + let f = setup(); + holds(&f, || guards::within_cap(&f.e, i128::MAX)); + + f.vault.set_deposit_cap(&Some(1_000), &f.admin); + let investor = f.investor(400); + f.vault.request_deposit(&investor, &400); + holds(&f, || guards::within_cap(&f.e, 600)); + refuses(&f, VaultError::DepositCapExceeded, || { + guards::within_cap(&f.e, 601) + }); + refuses(&f, VaultError::AmountTooLarge, || { + guards::within_cap(&f.e, i128::MAX) + }); +} + +#[test] +fn within_free_reserve_refuses_more_than_the_free_reserve() { + let f = setup(); + let _holder = f.holder(500); + + holds(&f, || guards::within_free_reserve(&f.e, 1_000)); + refuses(&f, VaultError::ReserveCommittedToExits, || { + guards::within_free_reserve(&f.e, 1_001) + }); +} + +#[test] +fn no_open_deposit_refuses_a_second_request_in_the_epoch() { + let f = setup(); + let investor = f.investor(1_000); + let current = f.vault.current_epoch(); + holds(&f, || guards::no_open_deposit(&f.e, current, &investor)); + + f.vault.request_deposit(&investor, &400); + refuses(&f, VaultError::RequestOutstanding, || { + guards::no_open_deposit(&f.e, current, &investor) + }); + holds(&f, || guards::no_open_deposit(&f.e, current + 1, &investor)); +} + +#[test] +fn no_open_redeem_refuses_a_second_request_in_the_epoch() { + let f = setup(); + let holder = f.holder(500); + let current = f.vault.current_epoch(); + holds(&f, || guards::no_open_redeem(&f.e, current, &holder)); + + f.vault.request_redeem(&holder, &100); + refuses(&f, VaultError::RequestOutstanding, || { + guards::no_open_redeem(&f.e, current, &holder) + }); + holds(&f, || guards::no_open_redeem(&f.e, current + 1, &holder)); +} + +#[test] +fn not_claimed_refuses_a_claimed_request() { + let f = setup(); + holds(&f, || guards::not_claimed(&f.e, false)); + refuses(&f, VaultError::AlreadyClaimed, || { + guards::not_claimed(&f.e, true) + }); +} + +/// Beyond the whole balance the guard stands aside: the token's own check +/// refuses that request. +#[test] +fn needs_no_frozen_shares_refuses_a_request_that_reaches_frozen_shares() { + let f = setup(); + let holder = f.holder(500); + f.share.freeze_partial_tokens(&holder, &400, &f.compliance); + + holds(&f, || guards::needs_no_frozen_shares(&f.e, &holder, 100)); + refuses(&f, VaultError::Frozen, || { + guards::needs_no_frozen_shares(&f.e, &holder, 101) + }); + refuses(&f, VaultError::Frozen, || { + guards::needs_no_frozen_shares(&f.e, &holder, 500) + }); + holds(&f, || guards::needs_no_frozen_shares(&f.e, &holder, 501)); +} + +#[test] +fn refund_covered_refuses_a_refund_beyond_the_free_reserve() { + let f = setup(); + let _holder = f.holder(500); + + holds(&f, || guards::refund_covered(&f.e, 1_000)); + refuses(&f, VaultError::ClaimNotCovered, || { + guards::refund_covered(&f.e, 1_001) + }); +} + +#[test] +fn payout_covered_refuses_a_payout_beyond_the_liquid_reserve() { + let f = setup(); + let _holder = f.holder(500); + let investor = f.investor(300); + f.vault.request_deposit(&investor, &300); + + holds(&f, || guards::payout_covered(&f.e, 1_000)); + refuses(&f, VaultError::ClaimNotCovered, || { + guards::payout_covered(&f.e, 1_001) + }); +} + +#[test] +fn eligible_to_receive_refuses_a_holder_the_token_would_not_take() { + let f = setup(); + let listed = f.investor(0); + holds(&f, || guards::eligible_to_receive(&f.e, &listed, 1)); + + let delisted = f.unverified_investor(0); + refuses(&f, VaultError::HolderNotEligible, || { + guards::eligible_to_receive(&f.e, &delisted, 1) + }); + + let frozen = f.investor(0); + f.share.set_address_frozen(&frozen, &true, &f.compliance); + refuses(&f, VaultError::HolderNotEligible, || { + guards::eligible_to_receive(&f.e, &frozen, 1) + }); + + let module = f.e.register(RefusesEveryTransfer, ()); + f.add_compliance_module(ComplianceHook::CanTransfer, &module); + refuses(&f, VaultError::HolderNotEligible, || { + guards::eligible_to_receive(&f.e, &listed, 1) + }); +} + +#[test] +fn epoch_open_refuses_a_sealed_epoch() { + let f = setup(); + let open = epoch(&f, f.vault.current_epoch()); + holds(&f, || guards::epoch_open(&f.e, &open)); + + let sealed = epoch(&f, f.close_epoch()); + refuses(&f, VaultError::EpochNotOpen, || { + guards::epoch_open(&f.e, &sealed) + }); +} + +#[test] +fn epoch_fulfilled_refuses_an_epoch_not_yet_priced() { + let f = setup(); + let id = f.close_epoch(); + let sealed = epoch(&f, id); + refuses(&f, VaultError::EpochNotFulfilled, || { + guards::epoch_fulfilled(&f.e, &sealed) + }); + + f.fulfill_epoch_at(id, wad(1)); + let priced = epoch(&f, id); + holds(&f, || guards::epoch_fulfilled(&f.e, &priced)); +} + +#[test] +fn not_priced_refuses_a_priced_epoch() { + let f = setup(); + let id = f.close_epoch(); + let sealed = epoch(&f, id); + holds(&f, || guards::not_priced(&f.e, &sealed)); + + f.fulfill_epoch_at(id, wad(1)); + let priced = epoch(&f, id); + refuses(&f, VaultError::AlreadyPriced, || { + guards::not_priced(&f.e, &priced) + }); +} + +#[test] +fn no_price_available_refuses_a_sealed_epoch_the_vault_could_price() { + let f = setup(); + let open = epoch(&f, f.vault.current_epoch()); + holds(&f, || guards::no_price_available(&f.e, &open)); + + let id = f.close_epoch(); + let sealed = epoch(&f, id); + holds(&f, || guards::no_price_available(&f.e, &sealed)); + + f.attest(wad(1)); + refuses(&f, VaultError::PriceAvailable, || { + guards::no_price_available(&f.e, &sealed) + }); + + f.vault.pause(&f.guardian); + holds(&f, || guards::no_price_available(&f.e, &sealed)); + f.vault.unpause(&f.admin); + + wind_down_now(&f); + f.attest(wad(1)); + holds(&f, || guards::no_price_available(&f.e, &sealed)); +} + +#[test] +fn fulfilable_refuses_an_epoch_the_vault_cannot_price_yet() { + let f = setup(); + let open = epoch(&f, f.vault.current_epoch()); + refuses(&f, VaultError::EpochNotPending, || { + guards::fulfilable(&f.e, &open) + }); + + let unpriced = epoch(&f, f.close_epoch()); + refuses(&f, VaultError::FeedNotValid, || { + guards::fulfilable(&f.e, &unpriced) + }); + f.attest(wad(1)); + holds(&f, || guards::fulfilable(&f.e, &unpriced)); + + f.advance(10); + let behind_the_report = epoch(&f, f.close_epoch()); + refuses(&f, VaultError::AttestationBeforeClose, || { + guards::fulfilable(&f.e, &behind_the_report) + }); + + f.vault.set_notice(&3_600, &f.admin); + let within_the_notice = epoch(&f, f.close_epoch()); + refuses(&f, VaultError::NoticeNotElapsed, || { + guards::fulfilable(&f.e, &within_the_notice) + }); +} + +#[test] +fn allowed_to_refuses_anything_but_going_ahead() { + let f = setup(); + let listed = f.investor(0); + let delisted = f.unverified_investor(0); + let frozen = f.investor(0); + f.share.set_address_frozen(&frozen, &true, &f.compliance); + + holds(&f, || { + guards::allowed_to(&f.e, Exit::RequestDeposit, &listed) + }); + refuses(&f, VaultError::NotAllowlisted, || { + guards::allowed_to(&f.e, Exit::RequestDeposit, &delisted) + }); + refuses(&f, VaultError::Frozen, || { + guards::allowed_to(&f.e, Exit::RequestDeposit, &frozen) + }); + + holds(&f, || { + guards::allowed_to(&f.e, Exit::RequestRedeem, &delisted) + }); + refuses(&f, VaultError::Frozen, || { + guards::allowed_to(&f.e, Exit::RequestRedeem, &frozen) + }); + + refuses(&f, VaultError::NotAllowlisted, || { + guards::allowed_to(&f.e, Exit::ClaimDeposit, &delisted) + }); +} + +#[test] +fn not_refused_refuses_a_refusal_or_a_hold() { + let f = setup(); + for decision in [ + Decision::Proceed, + Decision::ConvertToExit, + Decision::Surrender, + ] { + holds(&f, || { + guards::not_refused(&f.e, decision, Standing::Delisted) + }); + } + + refuses(&f, VaultError::NotAllowlisted, || { + guards::not_refused(&f.e, Decision::Refuse, Standing::Delisted) + }); + refuses(&f, VaultError::Frozen, || { + guards::not_refused(&f.e, Decision::Hold, Standing::Frozen) + }); +} diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index 7b719b32..9eb755bc 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -9,6 +9,7 @@ mod deposit_cap; mod epochs; mod events; mod governance; +mod guards; mod health; mod lock_policy; mod multi_epoch; diff --git a/contracts/async-vault/src/test/multi_epoch.rs b/contracts/async-vault/src/test/multi_epoch.rs index 0e19b678..c8996cba 100644 --- a/contracts/async-vault/src/test/multi_epoch.rs +++ b/contracts/async-vault/src/test/multi_epoch.rs @@ -82,7 +82,7 @@ fn the_rounding_residue_stays_in_the_reserve_once_the_epoch_settles() { /// outstanding-request check is keyed per epoch. Claims are taken newest first /// here, which today is allowed. #[test] -fn one_controller_redeems_across_two_epochs() { +fn one_holder_redeems_across_two_epochs() { let f = setup(); let a = f.holder(500); diff --git a/contracts/async-vault/src/test/settlement.rs b/contracts/async-vault/src/test/settlement.rs index 9976020f..9dfd4288 100644 --- a/contracts/async-vault/src/test/settlement.rs +++ b/contracts/async-vault/src/test/settlement.rs @@ -153,6 +153,35 @@ fn a_total_that_would_go_below_zero_fails_loudly() { f.e.as_contract(&f.vault.address, || crate::state::reduce(&f.e, 1, 2)); } +#[test] +fn a_settlement_total_that_would_overflow_fails_by_name() { + let f = setup(); + let epoch = f.vault.current_epoch(); + let full = crate::EpochSettlement { + deposits_open: u32::MAX, + redeems_open: u32::MAX, + shares_settled: i128::MAX, + assets_settled: i128::MAX, + ..Default::default() + }; + f.e.as_contract(&f.vault.address, || { + strata_storage::set_persistent(&f.e, &crate::keys::DataKey::Settlement(epoch), &full) + }); + + let steps: [&dyn Fn(); 4] = [ + &|| crate::settlement::on_deposit_requested(&f.e, epoch), + &|| crate::settlement::on_redeem_requested(&f.e, epoch), + &|| crate::settlement::on_deposit_claimed(&f.e, epoch, 1), + &|| crate::settlement::on_redeem_claimed(&f.e, epoch, 1), + ]; + for step in steps { + assert_eq!( + f.e.try_as_contract::<(), VaultError>(&f.vault.address, step), + Err(Ok(VaultError::AmountTooLarge)) + ); + } +} + /// A request that can never settle holds its epoch open, so the remainder /// stays in the totals. Here the redemption pays nothing and the holder, since /// delisted, cannot take the shares back. diff --git a/contracts/async-vault/src/test/share_returns.rs b/contracts/async-vault/src/test/share_returns.rs index 92cadfa5..799d38a2 100644 --- a/contracts/async-vault/src/test/share_returns.rs +++ b/contracts/async-vault/src/test/share_returns.rs @@ -5,7 +5,7 @@ use stellar_tokens::rwa::compliance::ComplianceHook; use super::*; #[contract] -struct RefusesEveryTransfer; +pub(super) struct RefusesEveryTransfer; #[contractimpl] impl RefusesEveryTransfer { @@ -34,7 +34,7 @@ fn a_delisted_vault_still_returns_escrowed_shares() { } #[test] -fn a_delisted_controller_cannot_take_shares_back() { +fn a_delisted_holder_cannot_take_shares_back() { let f = setup(); let holder = f.holder(500); let epoch = f.vault.request_redeem(&holder, &200); @@ -49,7 +49,7 @@ fn a_delisted_controller_cannot_take_shares_back() { } #[test] -fn a_frozen_controller_cannot_take_shares_back() { +fn a_frozen_holder_cannot_take_shares_back() { let f = setup(); let holder = f.holder(500); let epoch = f.vault.request_redeem(&holder, &200); @@ -121,7 +121,7 @@ fn a_partial_freeze_does_not_stop_a_return() { } #[test] -fn a_delisted_controller_with_a_worthless_exit_keeps_the_request() { +fn a_delisted_holder_with_a_worthless_exit_keeps_the_request() { let f = setup(); let holder = f.holder(10); let epoch = f.vault.request_redeem(&holder, &1); diff --git a/contracts/async-vault/src/test/supply.rs b/contracts/async-vault/src/test/supply.rs index 01b2a1be..dbe46bbc 100644 --- a/contracts/async-vault/src/test/supply.rs +++ b/contracts/async-vault/src/test/supply.rs @@ -1,3 +1,5 @@ +use soroban_sdk::{contract, contractimpl}; + use super::*; #[test] @@ -94,3 +96,36 @@ fn economic_supply_combines_circulating_and_pending_mint_correctly() { assert_eq!(f.vault.pending_mint_shares(), 0); assert_eq!(f.vault.total_economic_supply(), 360); } + +/// A share token whose books disagree: the vault holds more than exists. +#[contract] +struct SkewedShares; + +#[contractimpl] +impl SkewedShares { + pub fn total_supply(_e: Env) -> i128 { + 1 + } + + pub fn balance(_e: Env, _id: Address) -> i128 { + 2 + } +} + +#[test] +fn a_vault_holding_more_shares_than_exist_fails_loudly() { + let e = Env::default(); + let asset = e + .register_stellar_asset_contract_v2(Address::generate(&e)) + .address(); + let shares = e.register(SkewedShares, ()); + let vault = AsyncVaultClient::new( + &e, + &e.register(AsyncVault, (&asset, &shares, &asset, distinct_roles(&e))), + ); + + refused( + vault.try_total_economic_supply(), + VaultError::AccountingMismatch, + ); +} diff --git a/contracts/async-vault/src/test/treasury.rs b/contracts/async-vault/src/test/treasury.rs index 4fd928b8..a3823b2d 100644 --- a/contracts/async-vault/src/test/treasury.rs +++ b/contracts/async-vault/src/test/treasury.rs @@ -132,3 +132,27 @@ fn deploying_a_non_positive_amount_is_rejected() { assert!(f.vault.try_deploy_to_custodian(&f.treasury, &0).is_err()); assert!(f.vault.try_deploy_to_custodian(&f.treasury, &-1).is_err()); } + +/// Exits priced after a gain can owe more than the capital the vault counts, +/// so the result may be negative; only leaving the type's range is refused. +#[test] +fn deposited_capital_fails_by_name_instead_of_overflowing() { + let f = setup(); + let investor = f.investor(1); + f.vault.request_deposit(&investor, &1); + let capital = |net_deployed: i128, committed: i128| { + f.e.try_as_contract::(&f.vault.address, || { + crate::state::set_net_deployed(&f.e, net_deployed); + crate::state::set_committed(&f.e, committed); + crate::treasury::deposited_capital(&f.e) + }) + }; + + assert_eq!(capital(10, 3), Ok(8)); + assert_eq!(capital(0, 5), Ok(-4)); + assert_eq!(capital(i128::MAX, 0), Err(Ok(VaultError::AmountTooLarge))); + assert_eq!( + capital(i128::MIN, 2), + Err(Ok(VaultError::AccountingMismatch)) + ); +} diff --git a/contracts/async-vault/src/test/wind_down.rs b/contracts/async-vault/src/test/wind_down.rs index 811f8bb8..d8a28c77 100644 --- a/contracts/async-vault/src/test/wind_down.rs +++ b/contracts/async-vault/src/test/wind_down.rs @@ -178,7 +178,7 @@ fn a_delay_below_the_minimum_is_refused() { } /// Proposes, waits and activates. Returns nothing; the vault is active after. -fn wind_down_now(f: &Fixture) { +pub(super) fn wind_down_now(f: &Fixture) { f.vault.propose_wind_down(&f.admin); f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); @@ -853,3 +853,41 @@ fn a_partial_freeze_keeps_frozen_shares_out_of_a_wind_down_claim() { let rest = f.vault.claim_wind_down(&holder); assert_eq!(rest, 4 * first); } + +#[test] +fn a_snapshot_with_more_shares_to_burn_than_exist_fails_loudly() { + let f = setup(); + wind_down_now(&f); + f.e.as_contract(&f.vault.address, || { + crate::state::set_pending_burn_shares(&f.e, 1) + }); + + refused( + f.vault.try_finalize_wind_down_round(), + VaultError::AccountingMismatch, + ); +} + +#[test] +fn a_payout_below_what_was_already_paid_fails_loudly() { + let f = setup(); + let _holder = f.holder(500); + wind_down_now(&f); + f.vault.finalize_wind_down_round(); + let newcomer = f.investor(0); + f.e.as_contract(&f.vault.address, || { + strata_storage::set_persistent( + &f.e, + &crate::keys::DataKey::WindDownPosition(newcomer.clone()), + &crate::WindDownPosition { + entitlement: 0, + paid: 1, + }, + ) + }); + + refused( + f.vault.try_claim_wind_down(&newcomer), + VaultError::AccountingMismatch, + ); +} diff --git a/contracts/async-vault/src/treasury.rs b/contracts/async-vault/src/treasury.rs index e8d0c36c..9163e358 100644 --- a/contracts/async-vault/src/treasury.rs +++ b/contracts/async-vault/src/treasury.rs @@ -2,6 +2,7 @@ use soroban_sdk::{panic_with_error, token::TokenClient, Address, Env}; use crate::error::VaultError; use crate::event::{CustodianSet, Deployed, Funded}; +use crate::guards; use crate::keys::DataKey; use crate::state; use crate::wind_down; @@ -39,21 +40,19 @@ pub(crate) fn uncovered(e: &Env) -> i128 { /// Capital in the vault and deployed to the custodian, less liabilities already /// committed to priced exits. Used to evaluate headroom against the deposit cap. pub(crate) fn deposited_capital(e: &Env) -> i128 { - (held(e) + state::net_deployed(e)).saturating_sub(state::committed(e)) + held(e) + .checked_add(state::net_deployed(e)) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)) + .checked_sub(state::committed(e)) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AccountingMismatch)) } pub(crate) fn deploy(e: &Env, assets: i128) -> i128 { - wind_down::refuse_if_active(e); - if assets <= 0 { - panic_with_error!(e, VaultError::InvalidAmount); - } - + guards::not_winding_down(e); + guards::positive_amount(e, assets); let custodian = state::get_addr_opt(e, &DataKey::Custodian) .unwrap_or_else(|| panic_with_error!(e, VaultError::CustodianNotSet)); - - if assets > free_reserve(e) { - panic_with_error!(e, VaultError::ReserveCommittedToExits); - } + guards::within_free_reserve(e, assets); let net_deployed = state::net_deployed(e) .checked_add(assets) @@ -75,10 +74,7 @@ pub(crate) fn deploy(e: &Env, assets: i128) -> i128 { pub(crate) fn fund(e: &Env, caller: &Address, assets: i128) -> i128 { caller.require_auth(); - - if assets <= 0 { - panic_with_error!(e, VaultError::InvalidAmount); - } + guards::positive_amount(e, assets); let net_deployed = state::net_deployed(e) .checked_sub(assets) diff --git a/contracts/async-vault/src/upgrade.rs b/contracts/async-vault/src/upgrade.rs index 28000229..e045be5e 100644 --- a/contracts/async-vault/src/upgrade.rs +++ b/contracts/async-vault/src/upgrade.rs @@ -1,8 +1,6 @@ -use soroban_sdk::{panic_with_error, Env}; +use soroban_sdk::Env; -use crate::error::VaultError; use crate::keys::DataKey; -use crate::state; /// Seven days. Short enough to fix a defect in a week, long enough that an /// investor who dislikes the change has time to act on it. @@ -18,16 +16,3 @@ pub(crate) fn delay(e: &Env) -> u64 { pub(crate) fn set_delay(e: &Env, secs: u64) { strata_storage::set_instance(e, &DataKey::UpgradeDelay, &secs); } - -/// An exit takes a full notice, so a delay shorter than one is not a window. -pub(crate) fn check_delay(e: &Env, secs: u64) { - if secs < MIN_UPGRADE_DELAY { - panic_with_error!(e, VaultError::UpgradeDelayTooShort); - } - if secs > MAX_UPGRADE_DELAY { - panic_with_error!(e, VaultError::UpgradeDelayTooLong); - } - if secs < state::notice(e) { - panic_with_error!(e, VaultError::UpgradeDelayBelowNotice); - } -} diff --git a/contracts/async-vault/src/wind_down.rs b/contracts/async-vault/src/wind_down.rs index 6667abe9..71edb7aa 100644 --- a/contracts/async-vault/src/wind_down.rs +++ b/contracts/async-vault/src/wind_down.rs @@ -7,8 +7,9 @@ use crate::event::{ WindDownActivated, WindDownClaimed, WindDownDelaySet, WindDownProposalCancelled, WindDownProposed, WindDownRoundFinalized, }; +use crate::guards; use crate::keys::DataKey; -use crate::lock_policy::{self, Decision, Exit, Standing}; +use crate::lock_policy::{self, Exit, Standing}; use crate::state; use crate::treasury; @@ -53,18 +54,7 @@ pub(crate) fn delay(e: &Env) -> u64 { strata_storage::get_instance(e, &DataKey::WindDownDelay).unwrap_or(MIN_WIND_DOWN_DELAY) } -#[cfg_attr(feature = "short_wind_down", allow(clippy::absurd_extreme_comparisons))] -pub(crate) fn check_delay(e: &Env, secs: u64) { - if secs < MIN_WIND_DOWN_DELAY { - panic_with_error!(e, VaultError::WindDownDelayTooShort); - } - if secs > MAX_WIND_DOWN_DELAY { - panic_with_error!(e, VaultError::WindDownDelayTooLong); - } -} - pub(crate) fn set_delay(e: &Env, secs: u64) { - check_delay(e, secs); strata_storage::set_instance(e, &DataKey::WindDownDelay, &secs); WindDownDelaySet { secs }.publish(e); } @@ -87,25 +77,9 @@ pub(crate) fn is_active(e: &Env) -> bool { } } -/// Called at the top of every entrypoint the wind-down closes. -pub(crate) fn refuse_if_active(e: &Env) { - if is_active(e) { - panic_with_error!(e, VaultError::WindDownActive); - } -} - pub(crate) fn propose(e: &Env) { - match info(e) { - Some(WindDownInfo { - status: WindDownStatus::Active, - .. - }) => panic_with_error!(e, VaultError::WindDownActive), - Some(WindDownInfo { - status: WindDownStatus::Proposed, - .. - }) => panic_with_error!(e, VaultError::WindDownAlreadyProposed), - None => {} - } + guards::not_winding_down(e); + guards::no_wind_down_proposed(e); let active_at = e.ledger().timestamp().saturating_add(delay(e)); strata_storage::set_instance( @@ -122,31 +96,17 @@ pub(crate) fn propose(e: &Env) { } pub(crate) fn cancel_proposal(e: &Env) { - match info(e) { - Some(WindDownInfo { - status: WindDownStatus::Proposed, - .. - }) => {} - Some(WindDownInfo { - status: WindDownStatus::Active, - .. - }) => panic_with_error!(e, VaultError::WindDownActive), - None => panic_with_error!(e, VaultError::WindDownNotProposed), - } + guards::not_winding_down(e); + guards::wind_down_proposed(e); e.storage().instance().remove(&DataKey::WindDown); WindDownProposalCancelled {}.publish(e); } pub(crate) fn activate(e: &Env) { + guards::not_winding_down(e); let mut wd = info(e).unwrap_or_else(|| panic_with_error!(e, VaultError::WindDownNotProposed)); - - if wd.status == WindDownStatus::Active { - panic_with_error!(e, VaultError::WindDownActive); - } - if e.ledger().timestamp() < wd.active_at { - panic_with_error!(e, VaultError::WindDownDelayNotElapsed); - } + guards::wind_down_delay_elapsed(e, &wd); wd.status = WindDownStatus::Active; strata_storage::set_instance(e, &DataKey::WindDown, &wd); @@ -175,9 +135,8 @@ fn credited_before_snapshot(e: &Env) -> i128 { /// credited straight to a holder's entitlement. fn snapshot_supply(e: &Env) -> i128 { let share_token = state::get_addr(e, &DataKey::ShareToken); - FungibleTokenClient::new(e, &share_token) - .total_supply() - .saturating_sub(state::pending_burn_shares(e)) + let total_supply = FungibleTokenClient::new(e, &share_token).total_supply(); + state::reduce(e, total_supply, state::pending_burn_shares(e)) .checked_add(state::pending_mint_shares(e)) .and_then(|supply| supply.checked_add(credited_before_snapshot(e))) .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)) @@ -202,9 +161,7 @@ pub(crate) fn credit(e: &Env, holder: &Address, shares: i128) { pub(crate) fn finalize_round(e: &Env) -> i128 { let mut wd = info(e).unwrap_or_else(|| panic_with_error!(e, VaultError::WindDownNotActive)); - if wd.status != WindDownStatus::Active { - panic_with_error!(e, VaultError::WindDownNotActive); - } + guards::winding_down(e); let snapshot = if wd.round == 0 { let s = snapshot_supply(e); @@ -213,17 +170,12 @@ pub(crate) fn finalize_round(e: &Env) -> i128 { } else { supply_snapshot(e) }; - - if snapshot <= 0 { - panic_with_error!(e, VaultError::NothingToDistribute); - } + guards::something_to_distribute(e, snapshot); let pot = treasury::free_reserve(e); let delta = checked_mul_div_floor(e, &pot, &WAD_SCALE, &snapshot) .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); - if delta <= 0 { - panic_with_error!(e, VaultError::NothingToDistribute); - } + guards::something_to_distribute(e, delta); let acc_before = acc(e); let acc_per_share = acc_before @@ -237,7 +189,7 @@ pub(crate) fn finalize_round(e: &Env) -> i128 { checked_mul_div_floor(e, &snapshot, acc_value, &WAD_SCALE) .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)) }; - let credited = promised(&acc_per_share) - promised(&acc_before); + let credited = state::reduce(e, promised(&acc_per_share), promised(&acc_before)); let owed_total = owed(e) .checked_add(credited) @@ -289,19 +241,8 @@ pub(crate) fn claimable(e: &Env, holder: &Address) -> i128 { pub(crate) fn claim(e: &Env, holder: &Address) -> i128 { holder.require_auth(); - - let round = info(e).map(|wd| wd.round).unwrap_or(0); - if round == 0 { - panic_with_error!(e, VaultError::DistributionNotStarted); - } - - let standing = lock_policy::standing(e, holder); - match lock_policy::decide(Exit::ClaimWindDown, standing, is_active(e)) { - Decision::Proceed => {} - Decision::Refuse | Decision::Hold | Decision::ConvertToExit | Decision::Surrender => { - panic_with_error!(e, lock_policy::refused_with(standing)) - } - } + guards::distribution_started(e); + guards::allowed_to(e, Exit::ClaimWindDown, holder); let vault = e.current_contract_address(); let share_token = state::get_addr(e, &DataKey::ShareToken); @@ -317,11 +258,8 @@ pub(crate) fn claim(e: &Env, holder: &Address) -> i128 { let earned = checked_mul_div_floor(e, &p.entitlement, &acc(e), &WAD_SCALE) .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); - let assets = earned - p.paid; - - if surrendered == 0 && assets == 0 { - panic_with_error!(e, VaultError::NoEntitlement); - } + let assets = state::reduce(e, earned, p.paid); + guards::has_entitlement(e, surrendered, assets); p.paid = earned; strata_storage::set_persistent(e, &DataKey::WindDownPosition(holder.clone()), &p); diff --git a/contracts/compliance/src/governance.rs b/contracts/compliance/src/governance.rs index 657f157c..b158b049 100644 --- a/contracts/compliance/src/governance.rs +++ b/contracts/compliance/src/governance.rs @@ -6,6 +6,7 @@ use stellar_access::{ use stellar_tokens::rwa::{compliance, compliance::ComplianceHook, utils::token_binder}; use strata_timelock::{queue, setup, Clock, Refusal, TimelockHost}; +use crate::guards; use crate::{ ChangeApplied, ChangeCancelled, ChangeProposed, Compliance, ComplianceContractError, SetupFinished, VaultBound, @@ -63,13 +64,6 @@ fn vault_clock(e: &Env) -> Clock { ::clock(e).unwrap_or_else(|r| refuse(e, r)) } -/// Direct rule changes are instant only before setup finishes. -pub(crate) fn require_setup_open(e: &Env) { - if !setup::is_open(e) { - panic_with_error!(e, ComplianceContractError::UseTimelock); - } -} - pub(crate) fn bind_vault(e: &Env, vault: &Address) { setup::bind_source(e, vault).unwrap_or_else(|r| refuse(e, r)); VaultBound { @@ -78,14 +72,9 @@ pub(crate) fn bind_vault(e: &Env, vault: &Address) { .publish(e); } -/// No nominee from setup may accept once changes wait, so a hand-over offered -/// during setup holds it open. pub(crate) fn finish_setup(e: &Env) { - if setup::is_open(e) - && role_transfer::has_active_pending_transfer(e, &AccessControlStorageKey::PendingAdmin) - { - panic_with_error!(e, ComplianceContractError::AdminTransferPending); - } + guards::setup_open(e); + guards::no_hand_over_pending(e); setup::finish::(e).unwrap_or_else(|r| refuse(e, r)); SetupFinished {}.publish(e); } diff --git a/contracts/compliance/src/guards.rs b/contracts/compliance/src/guards.rs new file mode 100644 index 00000000..70cfb52d --- /dev/null +++ b/contracts/compliance/src/guards.rs @@ -0,0 +1,33 @@ +use soroban_sdk::{panic_with_error, Env}; +use stellar_access::{access_control::AccessControlStorageKey, role_transfer}; +use strata_timelock::setup; + +use crate::ComplianceContractError; + +pub(crate) fn setup_open(e: &Env) { + if !setup::is_open(e) { + panic_with_error!(e, ComplianceContractError::SetupClosed); + } +} + +/// No nominee from setup may accept once changes wait, so a hand-over offered +/// during setup holds it open. +pub(crate) fn no_hand_over_pending(e: &Env) { + if role_transfer::has_active_pending_transfer(e, &AccessControlStorageKey::PendingAdmin) { + panic_with_error!(e, ComplianceContractError::AdminTransferPending); + } +} + +/// Direct rule changes are instant only before setup finishes. +pub(crate) fn direct_change_allowed(e: &Env) { + if !setup::is_open(e) { + panic_with_error!(e, ComplianceContractError::UseTimelock); + } +} + +/// Only an offer needs setup open; cancelling one (`0`) keeps the admin. +pub(crate) fn direct_hand_over_allowed(e: &Env, live_until_ledger: u32) { + if live_until_ledger != 0 { + direct_change_allowed(e); + } +} diff --git a/contracts/compliance/src/lib.rs b/contracts/compliance/src/lib.rs index 8340e0ee..bb61ec4d 100644 --- a/contracts/compliance/src/lib.rs +++ b/contracts/compliance/src/lib.rs @@ -3,6 +3,7 @@ #![no_std] mod governance; +mod guards; use soroban_sdk::{ contract, contracterror, contractevent, contractimpl, contractmeta, Address, Env, Vec, @@ -138,9 +139,7 @@ impl Compliance { /// pending hand-over (`live_until_ledger == 0`) keeps the current admin, /// so it stays instant. pub fn transfer_admin_role(e: &Env, new_admin: Address, live_until_ledger: u32) { - if live_until_ledger != 0 { - governance::require_setup_open(e); - } + guards::direct_hand_over_allowed(e, live_until_ledger); strata_storage::bump_instance(e); access_control::transfer_admin_role(e, &new_admin, live_until_ledger); } @@ -172,14 +171,14 @@ impl VaultBinding for Compliance { impl token_binder::TokenBinder for Compliance { #[only_admin] fn bind_token(e: &Env, token: Address, _operator: Address) { - governance::require_setup_open(e); + guards::direct_change_allowed(e); strata_storage::bump_instance(e); token_binder::bind_token(e, &token); } #[only_admin] fn unbind_token(e: &Env, token: Address, _operator: Address) { - governance::require_setup_open(e); + guards::direct_change_allowed(e); strata_storage::bump_instance(e); token_binder::unbind_token(e, &token); } @@ -193,14 +192,14 @@ impl token_binder::TokenBinder for Compliance { impl compliance::Compliance for Compliance { #[only_admin] fn add_module_to(e: &Env, hook: ComplianceHook, module: Address, _operator: Address) { - governance::require_setup_open(e); + guards::direct_change_allowed(e); strata_storage::bump_instance(e); compliance::storage::add_module_to(e, hook, module); } #[only_admin] fn remove_module_from(e: &Env, hook: ComplianceHook, module: Address, _operator: Address) { - governance::require_setup_open(e); + guards::direct_change_allowed(e); strata_storage::bump_instance(e); compliance::storage::remove_module_from(e, hook, module); } diff --git a/contracts/compliance/src/test.rs b/contracts/compliance/src/test.rs index 07de0b74..35d5d75d 100644 --- a/contracts/compliance/src/test.rs +++ b/contracts/compliance/src/test.rs @@ -1,4 +1,5 @@ mod changes; +mod guards; use soroban_sdk::{ testutils::{storage::Instance as _, Address as _, Ledger as _}, diff --git a/contracts/compliance/src/test/guards.rs b/contracts/compliance/src/test/guards.rs new file mode 100644 index 00000000..4ab9bee9 --- /dev/null +++ b/contracts/compliance/src/test/guards.rs @@ -0,0 +1,116 @@ +//! Each guard passes while its condition holds and refuses with its error once +//! it does not. + +use soroban_sdk::{contract, contractimpl, testutils::Address as _, Address, Env, InvokeError}; +use strata_timelock::{Clock, MIN_DELAY}; + +use crate::guards; +use crate::{Compliance, ComplianceClient, ComplianceContractError}; + +/// Plays the vault: the contract binds only to an address that reports a clock. +#[contract] +struct VaultStub; + +#[contractimpl(contracttrait)] +impl strata_timelock::ClockSource for VaultStub { + fn timelock_clock(_e: &Env) -> Clock { + Clock { + delay: MIN_DELAY, + exit_window: 0, + paused_secs: 0, + paused: false, + winding_down: false, + } + } +} + +struct Fixture<'a> { + e: Env, + rules: ComplianceClient<'a>, + vault: Address, + admin: Address, +} + +impl Fixture<'_> { + fn finish_setup(&self) { + self.rules.bind_vault(&self.vault, &self.admin); + self.rules.finish_setup(&self.admin); + } +} + +fn setup<'a>() -> Fixture<'a> { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let rules = ComplianceClient::new(&e, &e.register(Compliance, (admin.clone(),))); + let vault = e.register(VaultStub, ()); + Fixture { + e, + rules, + vault, + admin, + } +} + +/// Runs `guard` inside the compliance contract, as an entrypoint would. +fn run(f: &Fixture, guard: impl FnOnce()) -> Result<(), Result> { + f.e.try_as_contract::<(), soroban_sdk::Error>(&f.rules.address, guard) +} + +fn holds(f: &Fixture, guard: impl FnOnce()) { + assert_eq!(run(f, guard), Ok(())); +} + +fn refuses(f: &Fixture, error: ComplianceContractError, guard: impl FnOnce()) { + assert_eq!(run(f, guard), Err(Ok(error.into()))); +} + +#[test] +fn setup_open_refuses_once_setup_has_finished() { + let f = setup(); + holds(&f, || guards::setup_open(&f.e)); + + f.finish_setup(); + refuses(&f, ComplianceContractError::SetupClosed, || { + guards::setup_open(&f.e) + }); +} + +#[test] +fn no_hand_over_pending_refuses_while_an_offer_is_open() { + let f = setup(); + holds(&f, || guards::no_hand_over_pending(&f.e)); + + let next = Address::generate(&f.e); + f.rules + .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)); + refuses(&f, ComplianceContractError::AdminTransferPending, || { + guards::no_hand_over_pending(&f.e) + }); + + f.rules.transfer_admin_role(&next, &0); + holds(&f, || guards::no_hand_over_pending(&f.e)); +} + +#[test] +fn direct_change_allowed_refuses_after_setup() { + let f = setup(); + holds(&f, || guards::direct_change_allowed(&f.e)); + + f.finish_setup(); + refuses(&f, ComplianceContractError::UseTimelock, || { + guards::direct_change_allowed(&f.e) + }); +} + +#[test] +fn direct_hand_over_allowed_refuses_only_an_offer_after_setup() { + let f = setup(); + holds(&f, || guards::direct_hand_over_allowed(&f.e, 100)); + + f.finish_setup(); + holds(&f, || guards::direct_hand_over_allowed(&f.e, 0)); + refuses(&f, ComplianceContractError::UseTimelock, || { + guards::direct_hand_over_allowed(&f.e, 100) + }); +} diff --git a/contracts/nav-oracle/src/governance.rs b/contracts/nav-oracle/src/governance.rs index aafa501d..153bc649 100644 --- a/contracts/nav-oracle/src/governance.rs +++ b/contracts/nav-oracle/src/governance.rs @@ -6,10 +6,11 @@ use stellar_access::{ use stellar_contract_utils::upgradeable; use strata_timelock::{queue, setup, Clock, Refusal, TimelockHost}; +use crate::guards; use crate::state::OracleConfig; use crate::{ - store_config, validate_config, ChangeApplied, ChangeCancelled, ChangeProposed, - NavOracleContract, OracleError, SetupFinished, VaultBound, + store_config, ChangeApplied, ChangeCancelled, ChangeProposed, NavOracleContract, OracleError, + SetupFinished, VaultBound, }; /// Seven days of five-second ledgers: how long the next admin has to accept a @@ -75,20 +76,6 @@ fn vault_clock(e: &Env) -> Clock { ::clock(e).unwrap_or_else(|r| refuse(e, r)) } -/// Direct admin calls are instant only before setup finishes. -pub(crate) fn require_setup_open(e: &Env) { - if !setup::is_open(e) { - panic_with_error!(e, OracleError::UseTimelock); - } -} - -fn upgrade_pending(e: &Env) -> bool { - queue::open_ids(e).iter().any(|id| { - queue::pending::(e, id) - .is_some_and(|(change, _)| matches!(change, OracleChange::Upgrade(_))) - }) -} - pub(crate) fn bind_vault(e: &Env, vault: &Address) { setup::bind_source(e, vault).unwrap_or_else(|r| refuse(e, r)); VaultBound { @@ -97,25 +84,16 @@ pub(crate) fn bind_vault(e: &Env, vault: &Address) { .publish(e); } -/// No nominee from setup may accept once changes wait, so a hand-over offered -/// during setup holds it open. pub(crate) fn finish_setup(e: &Env) { - if setup::is_open(e) - && role_transfer::has_active_pending_transfer(e, &AccessControlStorageKey::PendingAdmin) - { - panic_with_error!(e, OracleError::AdminTransferPending); - } + guards::setup_open(e); + guards::no_hand_over_pending(e); setup::finish::(e).unwrap_or_else(|r| refuse(e, r)); SetupFinished {}.publish(e); } pub(crate) fn propose(e: &Env, change: OracleChange) -> u32 { - if let OracleChange::Config(config) = &change { - validate_config(e, config); - } - if matches!(change, OracleChange::Upgrade(_)) && upgrade_pending(e) { - panic_with_error!(e, OracleError::UpgradeAlreadyPending); - } + guards::change_in_bounds(e, &change); + guards::not_a_second_upgrade(e, &change); let id = queue::propose::(e, &change).unwrap_or_else(|r| refuse(e, r)); let (_, schedule) = queue::pending::(e, id).unwrap_or_else(|| refuse(e, Refusal::NotFound)); @@ -139,6 +117,7 @@ pub(crate) fn apply(e: &Env, id: u32) { queue::take_ready::(e, id).unwrap_or_else(|r| refuse(e, r)); let admin = access_control::get_admin(e) .unwrap_or_else(|| panic_with_error!(e, OracleError::AdminRequired)); + guards::change_in_bounds(e, &change); match &change { OracleChange::Upgrade(hash) => upgradeable::upgrade(e, hash), OracleChange::GrantRole(role, account) => { @@ -148,10 +127,7 @@ pub(crate) fn apply(e: &Env, id: u32) { access_control::revoke_role_no_auth(e, account, role, &admin) } OracleChange::Admin(next) => offer_admin(e, &admin, next), - OracleChange::Config(config) => { - validate_config(e, config); - store_config(e, config); - } + OracleChange::Config(config) => store_config(e, config), } ChangeApplied { id, change }.publish(e); } diff --git a/contracts/nav-oracle/src/guards.rs b/contracts/nav-oracle/src/guards.rs new file mode 100644 index 00000000..7d9436d7 --- /dev/null +++ b/contracts/nav-oracle/src/guards.rs @@ -0,0 +1,133 @@ +use soroban_sdk::{panic_with_error, Env}; +use stellar_access::{access_control::AccessControlStorageKey, role_transfer}; +use strata_timelock::{queue, setup}; + +use crate::state::{self, get_config, NavReport, OracleConfig, OracleState}; +use crate::{ + compute_state, in_band, shortens_freshness_only, OracleChange, OracleError, BPS_DENOM, + MAX_ANSWER, MAX_FRESHNESS_SECS, MIN_COOLDOWN_SECS, +}; + +pub(crate) fn valid_config(e: &Env, cfg: &OracleConfig) { + if cfg.freshness_duration == 0 + || cfg.freshness_duration > MAX_FRESHNESS_SECS + || cfg.cooldown_secs < MIN_COOLDOWN_SECS + || cfg.cooldown_secs > cfg.freshness_duration + || cfg.min_answer <= 0 + || cfg.max_answer < cfg.min_answer + || cfg.max_answer > MAX_ANSWER + || !bps_in_range(cfg.max_up_bps) + || !bps_in_range(cfg.max_down_bps) + { + panic_with_error!(e, OracleError::InvalidConfig); + } +} + +fn bps_in_range(bps: u32) -> bool { + bps > 0 && bps as i128 <= BPS_DENOM +} + +pub(crate) fn setup_open(e: &Env) { + if !setup::is_open(e) { + panic_with_error!(e, OracleError::SetupClosed); + } +} + +/// No nominee from setup may accept once changes wait, so a hand-over offered +/// during setup holds it open. +pub(crate) fn no_hand_over_pending(e: &Env) { + if role_transfer::has_active_pending_transfer(e, &AccessControlStorageKey::PendingAdmin) { + panic_with_error!(e, OracleError::AdminTransferPending); + } +} + +/// Direct admin calls are instant only before setup finishes. +pub(crate) fn direct_change_allowed(e: &Env) { + if !setup::is_open(e) { + panic_with_error!(e, OracleError::UseTimelock); + } +} + +/// Only an offer needs setup open; cancelling one (`0`) keeps the admin. +pub(crate) fn direct_hand_over_allowed(e: &Env, live_until_ledger: u32) { + if live_until_ledger != 0 { + direct_change_allowed(e); + } +} + +pub(crate) fn direct_config_allowed(e: &Env, config: &OracleConfig) { + if !setup::is_open(e) && !shortens_freshness_only(&get_config(e), config) { + panic_with_error!(e, OracleError::UseTimelock); + } +} + +/// The bounds a change must meet when it is proposed and again when it +/// applies. +pub(crate) fn change_in_bounds(e: &Env, change: &OracleChange) { + if let OracleChange::Config(config) = change { + valid_config(e, config); + } +} + +/// One upgrade at a time, so a standing eta is never quietly replaced. +pub(crate) fn not_a_second_upgrade(e: &Env, change: &OracleChange) { + if matches!(change, OracleChange::Upgrade(_)) && upgrade_pending(e) { + panic_with_error!(e, OracleError::UpgradeAlreadyPending); + } +} + +fn upgrade_pending(e: &Env) -> bool { + queue::open_ids(e).iter().any(|id| { + queue::pending::(e, id) + .is_some_and(|(change, _)| matches!(change, OracleChange::Upgrade(_))) + }) +} + +pub(crate) fn consumable(e: &Env) { + if compute_state(e) != OracleState::Valid { + panic_with_error!(e, OracleError::NotConsumable); + } +} + +pub(crate) fn nav_not_paused(e: &Env) { + if state::nav_paused(e) { + panic_with_error!(e, OracleError::NavPaused); + } +} + +pub(crate) fn nav_paused(e: &Env) { + if !state::nav_paused(e) { + panic_with_error!(e, OracleError::NavNotPaused); + } +} + +pub(crate) fn nav_in_band(e: &Env, cfg: &OracleConfig, nav_per_share: i128) { + if !in_band(cfg, nav_per_share) { + panic_with_error!(e, OracleError::NavOutOfBand); + } +} + +pub(crate) fn expires_in_future(e: &Env, expires_at: u64) { + if expires_at <= e.ledger().timestamp() { + panic_with_error!(e, OracleError::ExpiresInPast); + } +} + +pub(crate) fn cooldown_elapsed(e: &Env, cfg: &OracleConfig, last: &NavReport) { + if e.ledger().timestamp() < last.timestamp.saturating_add(cfg.cooldown_secs) { + panic_with_error!(e, OracleError::CooldownActive); + } +} + +pub(crate) fn within_deviation(e: &Env, cfg: &OracleConfig, last: &NavReport, nav_per_share: i128) { + let bps = if nav_per_share >= last.nav_per_share { + cfg.max_up_bps + } else { + cfg.max_down_bps + }; + let diff = (nav_per_share - last.nav_per_share).abs(); + let bound = last.nav_per_share * (bps as i128) / BPS_DENOM; + if diff > bound { + panic_with_error!(e, OracleError::DeviationExceeded); + } +} diff --git a/contracts/nav-oracle/src/lib.rs b/contracts/nav-oracle/src/lib.rs index 12eecb99..2570e741 100644 --- a/contracts/nav-oracle/src/lib.rs +++ b/contracts/nav-oracle/src/lib.rs @@ -1,6 +1,7 @@ #![no_std] mod governance; +mod guards; mod state; use soroban_sdk::{ @@ -83,6 +84,9 @@ pub enum OracleError { /// once it is accepted, cancelled or expired, so no nominee from setup can /// accept after changes start to wait. AdminTransferPending = 3025, + /// The configuration the constructor writes is absent from instance + /// storage. + NotInitialized = 3026, } #[contractevent] @@ -141,25 +145,6 @@ pub struct ConfigSet { pub max_answer: i128, } -fn bps_in_range(bps: u32) -> bool { - bps > 0 && bps as i128 <= BPS_DENOM -} - -pub(crate) fn validate_config(e: &Env, cfg: &OracleConfig) { - if cfg.freshness_duration == 0 - || cfg.freshness_duration > MAX_FRESHNESS_SECS - || cfg.cooldown_secs < MIN_COOLDOWN_SECS - || cfg.cooldown_secs > cfg.freshness_duration - || cfg.min_answer <= 0 - || cfg.max_answer < cfg.min_answer - || cfg.max_answer > MAX_ANSWER - || !bps_in_range(cfg.max_up_bps) - || !bps_in_range(cfg.max_down_bps) - { - panic_with_error!(e, OracleError::InvalidConfig); - } -} - pub(crate) fn store_config(e: &Env, config: &OracleConfig) { state::set_config(e, config); ConfigSet { @@ -201,7 +186,7 @@ impl AccessControl for NavOracleContract { /// Revoking stays instant: taking an attester or a guardian away moves no /// value. fn grant_role(e: &Env, account: Address, role: Symbol, caller: Address) { - governance::require_setup_open(e); + guards::direct_change_allowed(e); strata_storage::bump_instance(e); access_control::grant_role(e, &account, &role, &caller); } @@ -209,9 +194,7 @@ impl AccessControl for NavOracleContract { /// Cancelling a pending hand-over (`live_until_ledger == 0`) keeps the /// current admin, so it stays instant. fn transfer_admin_role(e: &Env, new_admin: Address, live_until_ledger: u32) { - if live_until_ledger != 0 { - governance::require_setup_open(e); - } + guards::direct_hand_over_allowed(e, live_until_ledger); strata_storage::bump_instance(e); access_control::transfer_admin_role(e, &new_admin, live_until_ledger); } @@ -250,7 +233,7 @@ impl NavOracleContract { guardian: Address, config: OracleConfig, ) { - validate_config(e, &config); + guards::valid_config(e, &config); access_control::set_admin(e, &admin); access_control::grant_role_no_auth(e, &attester, &ATTESTER_ROLE, &admin); access_control::grant_role_no_auth(e, &guardian, &GUARDIAN_ROLE, &admin); @@ -321,44 +304,22 @@ impl NavOracleContract { } pub fn ensure_consumable(e: &Env) { - if compute_state(e) != OracleState::Valid { - panic_with_error!(e, OracleError::NotConsumable); - } + guards::consumable(e); } #[only_role(caller, "attester")] pub fn attest(e: &Env, report: NavReport, caller: Address) { - strata_storage::bump_instance(e); - if nav_paused(e) { - panic_with_error!(e, OracleError::NavPaused); - } + guards::nav_not_paused(e); let cfg = get_config(e); - let now = e.ledger().timestamp(); - - if !in_band(&cfg, report.nav_per_share) { - panic_with_error!(e, OracleError::NavOutOfBand); - } - if report.expires_at <= now { - // should never happen - panic_with_error!(e, OracleError::ExpiresInPast); - } - - if let Some(prev) = get_latest(e) { - if now < prev.timestamp.saturating_add(cfg.cooldown_secs) { - panic_with_error!(e, OracleError::CooldownActive); - } - let bps = if report.nav_per_share >= prev.nav_per_share { - cfg.max_up_bps - } else { - cfg.max_down_bps - }; - let diff = (report.nav_per_share - prev.nav_per_share).abs(); - let bound = prev.nav_per_share * (bps as i128) / BPS_DENOM; - if diff > bound { - panic_with_error!(e, OracleError::DeviationExceeded); - } + guards::nav_in_band(e, &cfg, report.nav_per_share); + guards::expires_in_future(e, report.expires_at); + if let Some(last) = get_latest(e) { + guards::cooldown_elapsed(e, &cfg, &last); + guards::within_deviation(e, &cfg, &last, report.nav_per_share); } + strata_storage::bump_instance(e); + let now = e.ledger().timestamp(); let stored = NavReport { nav_per_share: report.nav_per_share, timestamp: now, @@ -395,9 +356,7 @@ impl NavOracleContract { #[only_admin] pub fn clear_latest(e: &Env, caller: Address) { caller.require_auth(); - if !nav_paused(e) { - panic_with_error!(e, OracleError::NavNotPaused); - } + guards::nav_paused(e); state::clear_latest(e); LatestCleared {}.publish(e); } @@ -411,11 +370,8 @@ impl NavOracleContract { /// waits. Propose it as `OracleChange::Config`. #[only_admin] pub fn set_config(e: &Env, config: OracleConfig) { - validate_config(e, &config); - if !strata_timelock::setup::is_open(e) && !shortens_freshness_only(&get_config(e), &config) - { - panic_with_error!(e, OracleError::UseTimelock); - } + guards::valid_config(e, &config); + guards::direct_config_allowed(e, &config); store_config(e, &config); } } @@ -472,3 +428,5 @@ impl NavOracleContract { mod test; #[cfg(test)] mod test_governance; +#[cfg(test)] +mod test_guards; diff --git a/contracts/nav-oracle/src/state.rs b/contracts/nav-oracle/src/state.rs index 81786870..d0e74860 100644 --- a/contracts/nav-oracle/src/state.rs +++ b/contracts/nav-oracle/src/state.rs @@ -1,4 +1,6 @@ -use soroban_sdk::{contracttype, symbol_short, Env, Symbol}; +use soroban_sdk::{contracttype, panic_with_error, symbol_short, Env, Symbol}; + +use crate::OracleError; /// The three-state feed health // @@ -53,7 +55,8 @@ pub(crate) fn set_config(e: &Env, config: &OracleConfig) { } pub(crate) fn get_config(e: &Env) -> OracleConfig { - strata_storage::get_instance(e, &DataKey::Config).unwrap() + strata_storage::get_instance(e, &DataKey::Config) + .unwrap_or_else(|| panic_with_error!(e, OracleError::NotInitialized)) } pub(crate) fn set_nav_paused(e: &Env, paused: bool) { diff --git a/contracts/nav-oracle/src/test.rs b/contracts/nav-oracle/src/test.rs index bd435db8..29eb21d0 100644 --- a/contracts/nav-oracle/src/test.rs +++ b/contracts/nav-oracle/src/test.rs @@ -667,3 +667,16 @@ fn the_oracle_answers_the_feed_interface_the_vault_reads() { assert_eq!(feed.attested_at(), 10_000); assert_eq!(feed.freshness_duration(), 3600); } + +#[test] +fn a_missing_config_is_refused_by_name() { + let f = setup(); + f.e.as_contract(&f.oracle.address, || { + f.e.storage().instance().remove(&DataKey::Config) + }); + + assert_eq!( + f.oracle.try_config(), + Err(Ok(crate::OracleError::NotInitialized.into())) + ); +} diff --git a/contracts/nav-oracle/src/test_guards.rs b/contracts/nav-oracle/src/test_guards.rs new file mode 100644 index 00000000..934ff514 --- /dev/null +++ b/contracts/nav-oracle/src/test_guards.rs @@ -0,0 +1,367 @@ +//! Each guard passes while its condition holds and refuses with its error once +//! it does not. + +use soroban_sdk::{ + contract, contractimpl, symbol_short, + testutils::{Address as _, Ledger as _}, + Address, BytesN, Env, InvokeError, +}; +use strata_timelock::{Clock, MIN_DELAY}; + +use crate::guards; +use crate::{ + NavOracleContract, NavOracleContractClient, NavReport, OracleChange, OracleConfig, OracleError, + MAX_ANSWER, MAX_FRESHNESS_SECS, +}; + +const SCALE: i128 = 1_000_000_000_000_000_000; + +/// Plays the vault: the oracle binds only to an address that reports a clock. +#[contract] +struct VaultStub; + +#[contractimpl(contracttrait)] +impl strata_timelock::ClockSource for VaultStub { + fn timelock_clock(_e: &Env) -> Clock { + Clock { + delay: MIN_DELAY, + exit_window: 0, + paused_secs: 0, + paused: false, + winding_down: false, + } + } +} + +struct Fixture<'a> { + e: Env, + oracle: NavOracleContractClient<'a>, + vault: Address, + admin: Address, + attester: Address, + guardian: Address, +} + +impl Fixture<'_> { + fn finish_setup(&self) { + self.oracle.bind_vault(&self.vault, &self.admin); + self.oracle.finish_setup(&self.admin); + } + + fn attest(&self, nav_per_share: i128) { + self.oracle.attest( + &NavReport { + nav_per_share, + expires_at: u64::MAX, + timestamp: 0, + }, + &self.attester, + ); + } +} + +fn config() -> OracleConfig { + OracleConfig { + freshness_duration: 3_600, + cooldown_secs: 60, + max_up_bps: 1_000, + max_down_bps: 1_000, + min_answer: SCALE / 2, + max_answer: SCALE * 100, + } +} + +fn setup<'a>() -> Fixture<'a> { + let e = Env::default(); + e.mock_all_auths(); + e.ledger().set_timestamp(10_000); + let admin = Address::generate(&e); + let attester = Address::generate(&e); + let guardian = Address::generate(&e); + let oracle = NavOracleContractClient::new( + &e, + &e.register( + NavOracleContract, + (admin.clone(), attester.clone(), guardian.clone(), config()), + ), + ); + let vault = e.register(VaultStub, ()); + Fixture { + e, + oracle, + vault, + admin, + attester, + guardian, + } +} + +/// Runs `guard` inside the oracle, as an entrypoint would. +fn run(f: &Fixture, guard: impl FnOnce()) -> Result<(), Result> { + f.e.try_as_contract::<(), soroban_sdk::Error>(&f.oracle.address, guard) +} + +fn holds(f: &Fixture, guard: impl FnOnce()) { + assert_eq!(run(f, guard), Ok(())); +} + +fn refuses(f: &Fixture, error: OracleError, guard: impl FnOnce()) { + assert_eq!(run(f, guard), Err(Ok(error.into()))); +} + +fn report_at(nav_per_share: i128, timestamp: u64) -> NavReport { + NavReport { + nav_per_share, + expires_at: u64::MAX, + timestamp, + } +} + +#[test] +fn valid_config_refuses_a_config_outside_the_bounds() { + let f = setup(); + holds(&f, || guards::valid_config(&f.e, &config())); + + for invalid in [ + OracleConfig { + freshness_duration: 0, + ..config() + }, + OracleConfig { + freshness_duration: MAX_FRESHNESS_SECS + 1, + ..config() + }, + OracleConfig { + cooldown_secs: 0, + ..config() + }, + OracleConfig { + cooldown_secs: 3_601, + ..config() + }, + OracleConfig { + min_answer: 0, + ..config() + }, + OracleConfig { + max_answer: SCALE / 4, + ..config() + }, + OracleConfig { + max_answer: MAX_ANSWER + 1, + ..config() + }, + OracleConfig { + max_up_bps: 0, + ..config() + }, + OracleConfig { + max_down_bps: 10_001, + ..config() + }, + ] { + refuses(&f, OracleError::InvalidConfig, || { + guards::valid_config(&f.e, &invalid) + }); + } +} + +#[test] +fn setup_open_refuses_once_setup_has_finished() { + let f = setup(); + holds(&f, || guards::setup_open(&f.e)); + + f.finish_setup(); + refuses(&f, OracleError::SetupClosed, || guards::setup_open(&f.e)); +} + +#[test] +fn no_hand_over_pending_refuses_while_an_offer_is_open() { + let f = setup(); + holds(&f, || guards::no_hand_over_pending(&f.e)); + + let next = Address::generate(&f.e); + f.oracle + .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)); + refuses(&f, OracleError::AdminTransferPending, || { + guards::no_hand_over_pending(&f.e) + }); + + f.oracle.transfer_admin_role(&next, &0); + holds(&f, || guards::no_hand_over_pending(&f.e)); +} + +#[test] +fn direct_change_allowed_refuses_after_setup() { + let f = setup(); + holds(&f, || guards::direct_change_allowed(&f.e)); + + f.finish_setup(); + refuses(&f, OracleError::UseTimelock, || { + guards::direct_change_allowed(&f.e) + }); +} + +#[test] +fn direct_hand_over_allowed_refuses_only_an_offer_after_setup() { + let f = setup(); + holds(&f, || guards::direct_hand_over_allowed(&f.e, 100)); + + f.finish_setup(); + holds(&f, || guards::direct_hand_over_allowed(&f.e, 0)); + refuses(&f, OracleError::UseTimelock, || { + guards::direct_hand_over_allowed(&f.e, 100) + }); +} + +#[test] +fn direct_config_allowed_refuses_all_but_a_shorter_window_after_setup() { + let f = setup(); + let longer_window = OracleConfig { + freshness_duration: 7_200, + ..config() + }; + let shorter_window = OracleConfig { + freshness_duration: 1_800, + ..config() + }; + let tighter_cap = OracleConfig { + max_up_bps: 500, + ..config() + }; + holds(&f, || guards::direct_config_allowed(&f.e, &longer_window)); + + f.finish_setup(); + holds(&f, || guards::direct_config_allowed(&f.e, &config())); + holds(&f, || guards::direct_config_allowed(&f.e, &shorter_window)); + refuses(&f, OracleError::UseTimelock, || { + guards::direct_config_allowed(&f.e, &longer_window) + }); + refuses(&f, OracleError::UseTimelock, || { + guards::direct_config_allowed(&f.e, &tighter_cap) + }); +} + +#[test] +fn change_in_bounds_refuses_an_invalid_config() { + let f = setup(); + let upgrade = OracleChange::Upgrade(BytesN::from_array(&f.e, &[1; 32])); + let grant = OracleChange::GrantRole(symbol_short!("attester"), Address::generate(&f.e)); + let unbounded = OracleChange::Config(OracleConfig { + max_answer: MAX_ANSWER + 1, + ..config() + }); + + holds(&f, || guards::change_in_bounds(&f.e, &upgrade)); + holds(&f, || guards::change_in_bounds(&f.e, &grant)); + holds(&f, || { + guards::change_in_bounds(&f.e, &OracleChange::Config(config())) + }); + refuses(&f, OracleError::InvalidConfig, || { + guards::change_in_bounds(&f.e, &unbounded) + }); +} + +#[test] +fn not_a_second_upgrade_refuses_an_upgrade_while_one_waits() { + let f = setup(); + f.finish_setup(); + let upgrade = OracleChange::Upgrade(BytesN::from_array(&f.e, &[1; 32])); + let grant = OracleChange::GrantRole(symbol_short!("attester"), Address::generate(&f.e)); + holds(&f, || guards::not_a_second_upgrade(&f.e, &upgrade)); + + f.oracle.propose_change(&upgrade, &f.admin); + refuses(&f, OracleError::UpgradeAlreadyPending, || { + guards::not_a_second_upgrade(&f.e, &upgrade) + }); + holds(&f, || guards::not_a_second_upgrade(&f.e, &grant)); +} + +#[test] +fn consumable_refuses_a_feed_that_is_not_valid() { + let f = setup(); + refuses(&f, OracleError::NotConsumable, || guards::consumable(&f.e)); + + f.attest(SCALE); + holds(&f, || guards::consumable(&f.e)); + + f.oracle.pause_nav(&f.guardian); + refuses(&f, OracleError::NotConsumable, || guards::consumable(&f.e)); +} + +#[test] +fn nav_not_paused_refuses_while_the_nav_is_paused() { + let f = setup(); + holds(&f, || guards::nav_not_paused(&f.e)); + + f.oracle.pause_nav(&f.guardian); + refuses(&f, OracleError::NavPaused, || guards::nav_not_paused(&f.e)); +} + +#[test] +fn nav_paused_refuses_until_the_nav_is_paused() { + let f = setup(); + refuses(&f, OracleError::NavNotPaused, || guards::nav_paused(&f.e)); + + f.oracle.pause_nav(&f.guardian); + holds(&f, || guards::nav_paused(&f.e)); +} + +#[test] +fn nav_in_band_refuses_a_value_outside_the_band() { + let f = setup(); + let cfg = config(); + holds(&f, || guards::nav_in_band(&f.e, &cfg, cfg.min_answer)); + holds(&f, || guards::nav_in_band(&f.e, &cfg, cfg.max_answer)); + refuses(&f, OracleError::NavOutOfBand, || { + guards::nav_in_band(&f.e, &cfg, cfg.min_answer - 1) + }); + refuses(&f, OracleError::NavOutOfBand, || { + guards::nav_in_band(&f.e, &cfg, cfg.max_answer + 1) + }); +} + +#[test] +fn expires_in_future_refuses_a_report_born_stale() { + let f = setup(); + let now = f.e.ledger().timestamp(); + holds(&f, || guards::expires_in_future(&f.e, now + 1)); + refuses(&f, OracleError::ExpiresInPast, || { + guards::expires_in_future(&f.e, now) + }); +} + +#[test] +fn cooldown_elapsed_refuses_a_report_inside_the_cooldown() { + let f = setup(); + let cfg = config(); + let now = f.e.ledger().timestamp(); + let elapsed = report_at(SCALE, now - cfg.cooldown_secs); + let running = report_at(SCALE, now - cfg.cooldown_secs + 1); + + holds(&f, || guards::cooldown_elapsed(&f.e, &cfg, &elapsed)); + refuses(&f, OracleError::CooldownActive, || { + guards::cooldown_elapsed(&f.e, &cfg, &running) + }); +} + +#[test] +fn within_deviation_refuses_a_move_past_either_cap() { + let f = setup(); + let cfg = config(); + let last = report_at(SCALE, 0); + let ten_percent = SCALE / 10; + + holds(&f, || { + guards::within_deviation(&f.e, &cfg, &last, SCALE + ten_percent) + }); + holds(&f, || { + guards::within_deviation(&f.e, &cfg, &last, SCALE - ten_percent) + }); + refuses(&f, OracleError::DeviationExceeded, || { + guards::within_deviation(&f.e, &cfg, &last, SCALE + ten_percent + 1) + }); + refuses(&f, OracleError::DeviationExceeded, || { + guards::within_deviation(&f.e, &cfg, &last, SCALE - ten_percent - 1) + }); +} diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index bbff2999..f5a70045 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -1,6 +1,7 @@ use crate::error::ShareTokenError; use crate::event::LegalTransfer; use crate::governance::{self, TokenChange, TokenPendingChange}; +use crate::guards; use crate::roles::COMPLIANCE_ROLE; use soroban_sdk::{ @@ -12,27 +13,13 @@ use stellar_contract_utils::upgradeable; use stellar_macros::{only_admin, only_role}; use stellar_tokens::{ fungible::{Base, ContractOverrides, FungibleToken}, - rwa::{identity_verification::IdentityVerifierClient, RWAToken, RWA}, + rwa::{RWAToken, RWA}, }; use strata_bindings::VaultBinding; /// Longest order reference a legal transfer accepts, in bytes. pub const MAX_REFERENCE_LEN: u32 = 128; -/// The co-signing admin must be a different account from the caller. -fn require_admin_co_sign(e: &Env, caller: &Address) { - let admin = access_control::enforce_admin_auth(e); - if admin == *caller { - panic_with_error!(e, ShareTokenError::CoSignRequired); - } -} - -fn refuse_vault_address(e: &Env, account: &Address) { - if strata_timelock::setup::source(e).as_ref() == Some(account) { - panic_with_error!(e, ShareTokenError::VaultAddress); - } -} - #[contract] pub struct ShareToken; @@ -122,14 +109,12 @@ impl ShareToken { reference: String, caller: Address, ) { + guards::admin_co_signs(e, &caller); + guards::valid_reference(e, &reference); + guards::not_vault_address(e, &from); + guards::not_vault_address(e, &to); + guards::identity_verified(e, &to); strata_storage::bump_instance(e); - require_admin_co_sign(e, &caller); - if reference.is_empty() || reference.len() > MAX_REFERENCE_LEN { - panic_with_error!(e, ShareTokenError::InvalidReference); - } - refuse_vault_address(e, &from); - refuse_vault_address(e, &to); - IdentityVerifierClient::new(e, &RWA::identity_verifier(e)).verify_identity(&to); RWA::forced_transfer(e, &from, &to, amount); LegalTransfer { from, @@ -200,7 +185,7 @@ impl FungibleToken for ShareToken { impl AccessControl for ShareToken { /// Instant during setup; after it, propose the grant through the timelock. fn grant_role(e: &Env, account: Address, role: Symbol, caller: Address) { - governance::require_setup_open(e); + guards::direct_change_allowed(e); strata_storage::bump_instance(e); access_control::grant_role(e, &account, &role, &caller); } @@ -208,7 +193,7 @@ impl AccessControl for ShareToken { /// Instant for `compliance`. Revoking `manager` waits like a grant, /// because the vault needs it to pay claims. fn revoke_role(e: &Env, account: Address, role: Symbol, caller: Address) { - governance::require_revoke_allowed(e, &role); + guards::direct_revoke_allowed(e, &role); strata_storage::bump_instance(e); access_control::revoke_role(e, &account, &role, &caller); } @@ -216,9 +201,7 @@ impl AccessControl for ShareToken { /// Cancelling a pending hand-over (`live_until_ledger == 0`) keeps the /// current admin, so it stays instant. fn transfer_admin_role(e: &Env, new_admin: Address, live_until_ledger: u32) { - if live_until_ledger != 0 { - governance::require_setup_open(e); - } + guards::direct_hand_over_allowed(e, live_until_ledger); strata_storage::bump_instance(e); access_control::transfer_admin_role(e, &new_admin, live_until_ledger); } @@ -266,24 +249,22 @@ impl RWAToken for ShareToken { new_account: Address, operator: Address, ) -> bool { + guards::admin_co_signs(e, &operator); + guards::not_vault_address(e, &old_account); + guards::not_vault_address(e, &new_account); strata_storage::bump_instance(e); - require_admin_co_sign(e, &operator); - refuse_vault_address(e, &old_account); - refuse_vault_address(e, &new_account); RWA::recover_balance(e, &old_account, &new_account) } #[only_role(operator, "compliance")] fn set_address_frozen(e: &Env, user_address: Address, freeze: bool, operator: Address) { + guards::not_freezing_vault(e, &user_address, freeze); strata_storage::bump_instance(e); - if freeze { - refuse_vault_address(e, &user_address); - } RWA::set_address_frozen(e, &user_address, freeze); } #[only_role(operator, "compliance")] fn freeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address) { + guards::not_vault_address(e, &user_address); strata_storage::bump_instance(e); - refuse_vault_address(e, &user_address); RWA::freeze_partial_tokens(e, &user_address, amount); } #[only_role(operator, "compliance")] @@ -294,14 +275,14 @@ impl RWAToken for ShareToken { #[only_admin] fn set_compliance(e: &Env, compliance: Address, operator: Address) { operator.require_auth(); - governance::require_setup_open(e); + guards::direct_change_allowed(e); strata_storage::bump_instance(e); RWA::set_compliance(e, &compliance); } #[only_admin] fn set_identity_verifier(e: &Env, identity_verifier: Address, operator: Address) { operator.require_auth(); - governance::require_setup_open(e); + guards::direct_change_allowed(e); strata_storage::bump_instance(e); RWA::set_identity_verifier(e, &identity_verifier); } diff --git a/contracts/share-token/src/governance.rs b/contracts/share-token/src/governance.rs index 0407a211..bc4aa2ca 100644 --- a/contracts/share-token/src/governance.rs +++ b/contracts/share-token/src/governance.rs @@ -9,7 +9,7 @@ use strata_timelock::{queue, setup, Clock, Refusal, TimelockHost}; use crate::error::ShareTokenError; use crate::event::{ChangeApplied, ChangeCancelled, ChangeProposed, SetupFinished, VaultBound}; -use crate::roles::MANAGER_ROLE; +use crate::guards; use crate::ShareToken; /// Seven days of five-second ledgers: how long the next admin has to accept a @@ -63,28 +63,6 @@ fn vault_clock(e: &Env) -> Clock { ::clock(e).unwrap_or_else(|r| refuse(e, r)) } -/// Direct admin calls are instant only before setup finishes. -pub(crate) fn require_setup_open(e: &Env) { - if !setup::is_open(e) { - panic_with_error!(e, ShareTokenError::UseTimelock); - } -} - -/// Taking `manager` away stops every claim, so after setup it waits like a -/// grant. Taking any other role away moves no value and stays instant. -pub(crate) fn require_revoke_allowed(e: &Env, role: &Symbol) { - if *role == MANAGER_ROLE { - require_setup_open(e); - } -} - -fn upgrade_pending(e: &Env) -> bool { - queue::open_ids(e).iter().any(|id| { - queue::pending::(e, id) - .is_some_and(|(change, _)| matches!(change, TokenChange::Upgrade(_))) - }) -} - pub(crate) fn bind_vault(e: &Env, vault: &Address) { setup::bind_source(e, vault).unwrap_or_else(|r| refuse(e, r)); VaultBound { @@ -93,22 +71,15 @@ pub(crate) fn bind_vault(e: &Env, vault: &Address) { .publish(e); } -/// No nominee from setup may accept once changes wait, so a hand-over offered -/// during setup holds it open. pub(crate) fn finish_setup(e: &Env) { - if setup::is_open(e) - && role_transfer::has_active_pending_transfer(e, &AccessControlStorageKey::PendingAdmin) - { - panic_with_error!(e, ShareTokenError::AdminTransferPending); - } + guards::setup_open(e); + guards::no_hand_over_pending(e); setup::finish::(e).unwrap_or_else(|r| refuse(e, r)); SetupFinished {}.publish(e); } pub(crate) fn propose(e: &Env, change: TokenChange) -> u32 { - if matches!(change, TokenChange::Upgrade(_)) && upgrade_pending(e) { - panic_with_error!(e, ShareTokenError::UpgradeAlreadyPending); - } + guards::not_a_second_upgrade(e, &change); let id = queue::propose::(e, &change).unwrap_or_else(|r| refuse(e, r)); let (_, schedule) = queue::pending::(e, id).unwrap_or_else(|| refuse(e, Refusal::NotFound)); diff --git a/contracts/share-token/src/guards.rs b/contracts/share-token/src/guards.rs new file mode 100644 index 00000000..4f09e2cb --- /dev/null +++ b/contracts/share-token/src/guards.rs @@ -0,0 +1,94 @@ +use soroban_sdk::{panic_with_error, Address, Env, String, Symbol}; +use stellar_access::{ + access_control::{self, AccessControlStorageKey}, + role_transfer, +}; +use stellar_tokens::rwa::{identity_verification::IdentityVerifierClient, RWA}; +use strata_timelock::{queue, setup}; + +use crate::error::ShareTokenError; +use crate::governance::TokenChange; +use crate::roles::MANAGER_ROLE; +use crate::MAX_REFERENCE_LEN; + +pub(crate) fn setup_open(e: &Env) { + if !setup::is_open(e) { + panic_with_error!(e, ShareTokenError::SetupClosed); + } +} + +/// No nominee from setup may accept once changes wait, so a hand-over offered +/// during setup holds it open. +pub(crate) fn no_hand_over_pending(e: &Env) { + if role_transfer::has_active_pending_transfer(e, &AccessControlStorageKey::PendingAdmin) { + panic_with_error!(e, ShareTokenError::AdminTransferPending); + } +} + +/// Direct admin calls are instant only before setup finishes. +pub(crate) fn direct_change_allowed(e: &Env) { + if !setup::is_open(e) { + panic_with_error!(e, ShareTokenError::UseTimelock); + } +} + +/// Taking `manager` away stops every claim, so after setup it waits like a +/// grant. Taking any other role away moves no value and stays instant. +pub(crate) fn direct_revoke_allowed(e: &Env, role: &Symbol) { + if *role == MANAGER_ROLE { + direct_change_allowed(e); + } +} + +/// Only an offer needs setup open; cancelling one (`0`) keeps the admin. +pub(crate) fn direct_hand_over_allowed(e: &Env, live_until_ledger: u32) { + if live_until_ledger != 0 { + direct_change_allowed(e); + } +} + +/// One upgrade at a time, so a standing eta is never quietly replaced. +pub(crate) fn not_a_second_upgrade(e: &Env, change: &TokenChange) { + if matches!(change, TokenChange::Upgrade(_)) && upgrade_pending(e) { + panic_with_error!(e, ShareTokenError::UpgradeAlreadyPending); + } +} + +fn upgrade_pending(e: &Env) -> bool { + queue::open_ids(e).iter().any(|id| { + queue::pending::(e, id) + .is_some_and(|(change, _)| matches!(change, TokenChange::Upgrade(_))) + }) +} + +/// The admin signs too, and is a different account from the caller. +pub(crate) fn admin_co_signs(e: &Env, caller: &Address) { + let admin = access_control::enforce_admin_auth(e); + if admin == *caller { + panic_with_error!(e, ShareTokenError::CoSignRequired); + } +} + +pub(crate) fn valid_reference(e: &Env, reference: &String) { + if reference.is_empty() || reference.len() > MAX_REFERENCE_LEN { + panic_with_error!(e, ShareTokenError::InvalidReference); + } +} + +pub(crate) fn not_vault_address(e: &Env, account: &Address) { + if setup::source(e).as_ref() == Some(account) { + panic_with_error!(e, ShareTokenError::VaultAddress); + } +} + +/// Unfreezing the vault's address stays possible; freezing it never is. +pub(crate) fn not_freezing_vault(e: &Env, account: &Address, freeze: bool) { + if freeze { + not_vault_address(e, account); + } +} + +/// Fails with the identity verifier's own error. +pub(crate) fn identity_verified(e: &Env, account: &Address) { + IdentityVerifierClient::new(e, &RWA::identity_verifier(e)).verify_identity(account); +} diff --git a/contracts/share-token/src/lib.rs b/contracts/share-token/src/lib.rs index a4ac4d7f..5dbd9b0e 100644 --- a/contracts/share-token/src/lib.rs +++ b/contracts/share-token/src/lib.rs @@ -3,6 +3,7 @@ mod contract; mod error; mod event; mod governance; +mod guards; mod roles; pub use contract::*; pub use error::ShareTokenError; @@ -15,3 +16,5 @@ pub use governance::{TokenChange, TokenPendingChange, ADMIN_ACCEPT_LEDGERS}; mod test; #[cfg(test)] mod test_governance; +#[cfg(test)] +mod test_guards; diff --git a/contracts/share-token/src/test_guards.rs b/contracts/share-token/src/test_guards.rs new file mode 100644 index 00000000..48a3f995 --- /dev/null +++ b/contracts/share-token/src/test_guards.rs @@ -0,0 +1,251 @@ +//! Each guard passes while its condition holds and refuses with its error once +//! it does not. + +use soroban_sdk::{ + contract, contractimpl, symbol_short, + testutils::Address as _, + xdr::{ScErrorCode, ScErrorType}, + Address, BytesN, Env, InvokeError, String, Symbol, +}; +use stellar_tokens::rwa::RWAError; +use strata_timelock::{Clock, MIN_DELAY}; + +use crate::guards; +use crate::{ShareToken, ShareTokenClient, ShareTokenError, TokenChange, MAX_REFERENCE_LEN}; +use identity_verifier::{IdentityVerifier, IdentityVerifierClient}; + +/// Plays the vault: the token binds only to an address that reports a clock. +#[contract] +struct VaultStub; + +#[contractimpl(contracttrait)] +impl strata_timelock::ClockSource for VaultStub { + fn timelock_clock(_e: &Env) -> Clock { + Clock { + delay: MIN_DELAY, + exit_window: 0, + paused_secs: 0, + paused: false, + winding_down: false, + } + } +} + +struct Fixture<'a> { + e: Env, + token: ShareTokenClient<'a>, + identity: IdentityVerifierClient<'a>, + vault: Address, + admin: Address, + compliance_authority: Address, +} + +impl Fixture<'_> { + fn finish_setup(&self) { + self.token.bind_vault(&self.vault, &self.admin); + self.token.finish_setup(&self.admin); + } +} + +fn setup<'a>() -> Fixture<'a> { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let compliance_authority = Address::generate(&e); + let identity = IdentityVerifierClient::new(&e, &e.register(IdentityVerifier, (admin.clone(),))); + let token = ShareTokenClient::new( + &e, + &e.register( + ShareToken, + ( + String::from_str(&e, "Strata Vault USDC"), + String::from_str(&e, "bvUSDC"), + admin.clone(), + compliance_authority.clone(), + Address::generate(&e), + identity.address.clone(), + ), + ), + ); + let vault = e.register(VaultStub, ()); + Fixture { + e, + token, + identity, + vault, + admin, + compliance_authority, + } +} + +/// Runs `guard` inside the token, as an entrypoint would. +fn run(f: &Fixture, guard: impl FnOnce()) -> Result<(), Result> { + f.e.try_as_contract::<(), soroban_sdk::Error>(&f.token.address, guard) +} + +fn holds(f: &Fixture, guard: impl FnOnce()) { + assert_eq!(run(f, guard), Ok(())); +} + +fn refuses(f: &Fixture, error: impl Into, guard: impl FnOnce()) { + assert_eq!(run(f, guard), Err(Ok(error.into()))); +} + +#[test] +fn setup_open_refuses_once_setup_has_finished() { + let f = setup(); + holds(&f, || guards::setup_open(&f.e)); + + f.finish_setup(); + refuses(&f, ShareTokenError::SetupClosed, || { + guards::setup_open(&f.e) + }); +} + +#[test] +fn no_hand_over_pending_refuses_while_an_offer_is_open() { + let f = setup(); + holds(&f, || guards::no_hand_over_pending(&f.e)); + + let next = Address::generate(&f.e); + f.token + .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)); + refuses(&f, ShareTokenError::AdminTransferPending, || { + guards::no_hand_over_pending(&f.e) + }); + + f.token.transfer_admin_role(&next, &0); + holds(&f, || guards::no_hand_over_pending(&f.e)); +} + +#[test] +fn not_a_second_upgrade_refuses_an_upgrade_while_one_waits() { + let f = setup(); + f.finish_setup(); + let upgrade = TokenChange::Upgrade(BytesN::from_array(&f.e, &[1; 32])); + let grant = TokenChange::GrantRole(symbol_short!("manager"), Address::generate(&f.e)); + holds(&f, || guards::not_a_second_upgrade(&f.e, &upgrade)); + + f.token.propose_change(&upgrade, &f.admin); + refuses(&f, ShareTokenError::UpgradeAlreadyPending, || { + guards::not_a_second_upgrade(&f.e, &upgrade) + }); + holds(&f, || guards::not_a_second_upgrade(&f.e, &grant)); +} + +#[test] +fn admin_co_signs_refuses_one_account_signing_as_both() { + let f = setup(); + holds(&f, || guards::admin_co_signs(&f.e, &f.compliance_authority)); + refuses(&f, ShareTokenError::CoSignRequired, || { + guards::admin_co_signs(&f.e, &f.admin) + }); + + f.e.set_auths(&[]); + assert_eq!( + run(&f, || guards::admin_co_signs(&f.e, &f.compliance_authority)), + Err(Ok(soroban_sdk::Error::from_type_and_code( + ScErrorType::Auth, + ScErrorCode::InvalidAction + ))) + ); +} + +#[test] +fn valid_reference_refuses_an_empty_or_overlong_reference() { + let f = setup(); + let bytes = [b'x'; MAX_REFERENCE_LEN as usize + 1]; + let reference = |len: u32| String::from_bytes(&f.e, &bytes[..len as usize]); + let (empty, shortest, longest, overlong) = ( + reference(0), + reference(1), + reference(MAX_REFERENCE_LEN), + reference(MAX_REFERENCE_LEN + 1), + ); + + holds(&f, || guards::valid_reference(&f.e, &shortest)); + holds(&f, || guards::valid_reference(&f.e, &longest)); + refuses(&f, ShareTokenError::InvalidReference, || { + guards::valid_reference(&f.e, &empty) + }); + refuses(&f, ShareTokenError::InvalidReference, || { + guards::valid_reference(&f.e, &overlong) + }); +} + +#[test] +fn not_vault_address_refuses_the_bound_vault() { + let f = setup(); + let holder = Address::generate(&f.e); + holds(&f, || guards::not_vault_address(&f.e, &f.vault)); + + f.token.bind_vault(&f.vault, &f.admin); + refuses(&f, ShareTokenError::VaultAddress, || { + guards::not_vault_address(&f.e, &f.vault) + }); + holds(&f, || guards::not_vault_address(&f.e, &holder)); +} + +#[test] +fn not_freezing_vault_refuses_only_a_freeze_of_the_vault() { + let f = setup(); + let holder = Address::generate(&f.e); + f.token.bind_vault(&f.vault, &f.admin); + + refuses(&f, ShareTokenError::VaultAddress, || { + guards::not_freezing_vault(&f.e, &f.vault, true) + }); + holds(&f, || guards::not_freezing_vault(&f.e, &f.vault, false)); + holds(&f, || guards::not_freezing_vault(&f.e, &holder, true)); +} + +#[test] +fn identity_verified_refuses_an_account_the_verifier_rejects() { + let f = setup(); + let listed = Address::generate(&f.e); + let stranger = Address::generate(&f.e); + f.identity.allow(&listed, &true, &f.admin); + + holds(&f, || guards::identity_verified(&f.e, &listed)); + refuses(&f, RWAError::IdentityVerificationFailed, || { + guards::identity_verified(&f.e, &stranger) + }); +} + +#[test] +fn direct_change_allowed_refuses_after_setup() { + let f = setup(); + holds(&f, || guards::direct_change_allowed(&f.e)); + + f.finish_setup(); + refuses(&f, ShareTokenError::UseTimelock, || { + guards::direct_change_allowed(&f.e) + }); +} + +#[test] +fn direct_revoke_allowed_refuses_only_the_manager_role_after_setup() { + let f = setup(); + let compliance = Symbol::new(&f.e, "compliance"); + holds(&f, || { + guards::direct_revoke_allowed(&f.e, &symbol_short!("manager")) + }); + + f.finish_setup(); + holds(&f, || guards::direct_revoke_allowed(&f.e, &compliance)); + refuses(&f, ShareTokenError::UseTimelock, || { + guards::direct_revoke_allowed(&f.e, &symbol_short!("manager")) + }); +} + +#[test] +fn direct_hand_over_allowed_refuses_only_an_offer_after_setup() { + let f = setup(); + holds(&f, || guards::direct_hand_over_allowed(&f.e, 100)); + + f.finish_setup(); + holds(&f, || guards::direct_hand_over_allowed(&f.e, 0)); + refuses(&f, ShareTokenError::UseTimelock, || { + guards::direct_hand_over_allowed(&f.e, 100) + }); +}