From fbeb012bfc9c7109ce813eaae5221b2089269729 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:13:21 -0300 Subject: [PATCH 1/3] feat(vault): timelock governance after setup --- contracts/async-vault/Cargo.toml | 2 + contracts/async-vault/src/deposit.rs | 2 + contracts/async-vault/src/error.rs | 18 + contracts/async-vault/src/event.rs | 3 + contracts/async-vault/src/governance.rs | 192 ++++++++- contracts/async-vault/src/lib.rs | 80 +++- contracts/async-vault/src/test/cancel.rs | 2 - contracts/async-vault/src/test/compliance.rs | 1 + contracts/async-vault/src/test/controls.rs | 21 +- contracts/async-vault/src/test/delisted.rs | 6 + contracts/async-vault/src/test/deposit_cap.rs | 1 - contracts/async-vault/src/test/governance.rs | 392 ++++++++++++++++++ contracts/async-vault/src/test/mod.rs | 101 ++++- contracts/async-vault/src/test/multi_epoch.rs | 1 - contracts/async-vault/src/test/setup.rs | 163 ++++++++ contracts/async-vault/src/test/treasury.rs | 19 +- contracts/async-vault/src/test/upgrade.rs | 4 +- contracts/async-vault/src/test/wind_down.rs | 37 +- contracts/async-vault/src/wind_down.rs | 20 +- environments.toml | 10 +- scripts/harness/deploy.ts | 29 ++ scripts/harness/guards.test.ts | 70 +++- scripts/harness/happy-path.test.ts | 19 +- scripts/harness/seed.ts | 14 +- 24 files changed, 1088 insertions(+), 119 deletions(-) create mode 100644 contracts/async-vault/src/test/governance.rs create mode 100644 contracts/async-vault/src/test/setup.rs diff --git a/contracts/async-vault/Cargo.toml b/contracts/async-vault/Cargo.toml index da34cabb..ecd0b862 100644 --- a/contracts/async-vault/Cargo.toml +++ b/contracts/async-vault/Cargo.toml @@ -30,3 +30,5 @@ proptest = "1" [features] upgrade_wasm = [] +# Drops the wind-down minimum to zero for the local harness. Never deployed. +short_wind_down = [] diff --git a/contracts/async-vault/src/deposit.rs b/contracts/async-vault/src/deposit.rs index d6421414..bc9da0a5 100644 --- a/contracts/async-vault/src/deposit.rs +++ b/contracts/async-vault/src/deposit.rs @@ -4,6 +4,7 @@ use stellar_tokens::rwa::RWATokenClient; use crate::epoch; use crate::error::VaultError; use crate::event::{DepositCancelled, DepositClaimed, DepositConvertedToExit, DepositRequested}; +use crate::governance; use crate::keys::DataKey; use crate::lock_policy::{self, Decision, Exit, Standing}; use crate::pricing::{Pricing, PricingScheme}; @@ -37,6 +38,7 @@ fn refuse_if_exceeds_cap(e: &Env, amount: i128) { pub(crate) fn request(e: &Env, from: &Address, amount: i128) -> u64 { wind_down::refuse_if_active(e); + governance::refuse_if_setup_open(e); from.require_auth(); if amount <= 0 { diff --git a/contracts/async-vault/src/error.rs b/contracts/async-vault/src/error.rs index bedafb71..cb2c1e23 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -115,9 +115,23 @@ pub enum VaultError { /// As many changes as the timelock holds already wait. Apply or cancel one /// first. TooManyPendingChanges = 6063, + /// Setup is still open, so the timelock protects nothing yet: the vault + /// takes no deposit and queues no change. Governance finishes setup first. + SetupOpen = 6064, + /// A direct call cannot make this change. After setup it goes through + /// `propose_change`; setting a role admin is never allowed. + UseTimelock = 6065, + /// Setup has finished, and it never reopens. + SetupClosed = 6066, + /// The token, the oracle, the manager role, the allowlist or the + /// compliance binding is not wired to this vault yet, so setup cannot + /// finish. + SetupIncomplete = 6067, /// The holder fails the share token's identity check, so the vault takes no /// deposit request from them and returns no escrowed shares to them. NotAllowlisted = 6068, + /// The wind-down delay is below the shortest the vault accepts. + WindDownDelayTooShort = 6069, /// A running total would go below zero, which means the vault's books /// disagree with themselves. Fails loudly instead of clamping to zero. AccountingMismatch = 6070, @@ -125,4 +139,8 @@ pub enum VaultError { /// returned or moved, until compliance lifts the freeze. A partial freeze /// refuses only what would touch frozen shares. Frozen = 6071, + /// An admin hand-over offered during setup is still open. Setup finishes + /// once it is accepted, cancelled or expired, so no nominee from setup can + /// accept after changes start to wait. + AdminTransferPending = 6074, } diff --git a/contracts/async-vault/src/event.rs b/contracts/async-vault/src/event.rs index 33c55f68..d825af74 100644 --- a/contracts/async-vault/src/event.rs +++ b/contracts/async-vault/src/event.rs @@ -116,6 +116,9 @@ pub struct ChangeApplied { pub change: VaultChange, } +#[contractevent] +pub struct SetupFinished {} + #[contractevent] pub struct WindDownDelaySet { pub secs: u64, diff --git a/contracts/async-vault/src/governance.rs b/contracts/async-vault/src/governance.rs index bf993c51..a4c5f196 100644 --- a/contracts/async-vault/src/governance.rs +++ b/contracts/async-vault/src/governance.rs @@ -1,16 +1,32 @@ -use soroban_sdk::{contracttype, panic_with_error, BytesN, Env, Vec}; +use bindings::VaultBindingClient; +use soroban_sdk::{contracttype, panic_with_error, Address, BytesN, Env, Symbol, Vec}; +use stellar_access::{ + access_control::{self, AccessControlClient, AccessControlStorageKey}, + role_transfer, +}; use stellar_contract_utils::{ pausable::{self, PausableError}, upgradeable, }; -use timelock::{queue, Clock, Refusal}; +use stellar_tokens::rwa::{ + identity_verification::IdentityVerifierClient, utils::token_binder::TokenBinderClient, + RWATokenClient, +}; +use timelock::{queue, setup, Clock, Refusal}; use crate::error::VaultError; -use crate::event::{ChangeApplied, ChangeCancelled, ChangeProposed}; +use crate::event::{ChangeApplied, ChangeCancelled, ChangeProposed, NoticeSet, SetupFinished}; +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; +use crate::{AsyncVault, MAX_NOTICE_SECS}; + +/// Seven days of five-second ledgers: how long the next admin has to accept a +/// hand-over. +pub const ADMIN_ACCEPT_LEDGERS: u32 = 7 * 17_280; /// A privileged change that waits for the timelock before it lands. #[contracttype] @@ -18,6 +34,12 @@ use crate::AsyncVault; pub enum VaultChange { Upgrade(BytesN<32>), UpgradeDelay(u64), + Custodian(Address), + Notice(u64), + WindDownDelay(u64), + GrantRole(Symbol, Address), + RevokeRole(Symbol, Address), + Admin(Address), } #[contracttype] @@ -28,6 +50,25 @@ pub struct VaultPendingChange { pub eta: u64, } +/// What `finish_setup` needs to see before deposits open. +pub(crate) struct Wiring { + pub token_bound: bool, + pub oracle_bound: bool, + pub sole_token_manager: bool, + pub allowlisted: bool, + pub compliance_bound: bool, +} + +impl Wiring { + fn complete(&self) -> bool { + self.token_bound + && self.oracle_bound + && self.sole_token_manager + && self.allowlisted + && self.compliance_bound + } +} + /// The clock this vault's queue runs on, and that it reports to the contracts /// bound to it. pub(crate) fn clock(e: &Env) -> Clock { @@ -40,7 +81,7 @@ pub(crate) fn clock(e: &Env) -> Clock { } } -pub(crate) fn refuse(e: &Env, refusal: Refusal) -> ! { +fn refuse(e: &Env, refusal: Refusal) -> ! { match refusal { Refusal::WindingDown => panic_with_error!(e, VaultError::WindDownActive), Refusal::Paused => panic_with_error!(e, PausableError::EnforcedPause), @@ -48,13 +89,98 @@ pub(crate) fn refuse(e: &Env, refusal: Refusal) -> ! { Refusal::BelowExitWindow => panic_with_error!(e, VaultError::UpgradeDelayBelowNotice), Refusal::NotFound => panic_with_error!(e, VaultError::ChangeNotFound), Refusal::TooMany => panic_with_error!(e, VaultError::TooManyPendingChanges), - // The constructor closes setup and binds nothing, so these mean a broken instance. - Refusal::SetupOpen | Refusal::SetupClosed | Refusal::Unbound | Refusal::AlreadyBound => { - panic_with_error!(e, VaultError::NotInitialized) - } + Refusal::SetupOpen => panic_with_error!(e, VaultError::SetupOpen), + Refusal::SetupClosed => panic_with_error!(e, VaultError::SetupClosed), + // The vault owns the clock and binds to nothing, so these two never arise. + Refusal::AlreadyBound => panic_with_error!(e, VaultError::SetupClosed), + Refusal::Unbound => panic_with_error!(e, VaultError::SetupIncomplete), + } +} + +/// 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 { + let binding = VaultBindingClient::new(e, contract); + matches!(binding.try_vault(), Ok(Ok(Some(vault))) if vault == e.current_contract_address()) + && matches!(binding.try_setup_open(), Ok(Ok(false))) +} + +pub(crate) fn wiring(e: &Env) -> Wiring { + let me = e.current_contract_address(); + let token = state::get_addr(e, &DataKey::ShareToken); + let oracle = state::get_addr(e, &DataKey::Oracle); + let roles = AccessControlClient::new(e, &token); + let rwa = RWATokenClient::new(e, &token); + Wiring { + token_bound: bound_here(e, &token), + oracle_bound: bound_here(e, &oracle), + sole_token_manager: roles.get_role_member_count(&MANAGER_ROLE) == 1 + && roles.get_role_member(&MANAGER_ROLE, &0) == me, + allowlisted: matches!( + IdentityVerifierClient::new(e, &rwa.identity_verifier()).try_verify_identity(&me), + Ok(Ok(())) + ), + compliance_bound: matches!( + TokenBinderClient::new(e, &rwa.compliance()).try_linked_tokens(), + Ok(Ok(linked)) if linked.contains(&token) + ), + } +} + +/// 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); + } + 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, @@ -69,11 +195,25 @@ fn code_change_pending(e: &Env) -> bool { }) } +/// 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::Upgrade(_) + | VaultChange::Custodian(_) + | VaultChange::GrantRole(..) + | VaultChange::RevokeRole(..) + | VaultChange::Admin(_) => {} + } +} + pub(crate) fn propose(e: &Env, change: VaultChange) -> u32 { wind_down::refuse_if_active(e); - if let VaultChange::UpgradeDelay(secs) = change { - upgrade::check_delay(e, secs); - } + refuse_if_setup_open(e); + check(e, &change); if changes_code(&change) && code_change_pending(e) { panic_with_error!(e, VaultError::UpgradeProposalExists); } @@ -95,17 +235,35 @@ pub(crate) fn cancel(e: &Env, id: u32) { 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); + let admin = access_control::get_admin(e) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AdminRequired)); match &change { - VaultChange::UpgradeDelay(secs) => { - // The notice may have moved since the proposal; this check decides. - upgrade::check_delay(e, *secs); - upgrade::set_delay(e, *secs); - } VaultChange::Upgrade(hash) => upgradeable::upgrade(e, hash), + VaultChange::UpgradeDelay(secs) => upgrade::set_delay(e, *secs), + VaultChange::Custodian(custodian) => treasury::set_custodian(e, custodian), + VaultChange::Notice(secs) => set_notice(e, *secs), + VaultChange::WindDownDelay(secs) => wind_down::set_delay(e, *secs), + VaultChange::GrantRole(role, account) => { + access_control::grant_role_no_auth(e, account, role, &admin) + } + VaultChange::RevokeRole(role, account) => { + access_control::revoke_role_no_auth(e, account, role, &admin) + } + VaultChange::Admin(next) => offer_admin(e, &admin, next), } ChangeApplied { id, change }.publish(e); } +/// The first step of the two-step hand-over, without the admin authorisation +/// `transfer_admin_role` would ask for a second time inside `apply_change`. +fn offer_admin(e: &Env, admin: &Address, next: &Address) { + let live_until = e.ledger().sequence().saturating_add(ADMIN_ACCEPT_LEDGERS); + role_transfer::transfer_role(e, next, &AccessControlStorageKey::PendingAdmin, live_until); + access_control::emit_admin_transfer_initiated(e, admin, next, live_until); +} + pub(crate) fn pending(e: &Env) -> Vec { let clock = clock(e); let mut out = Vec::new(e); diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index bbccd4aa..d8f29593 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -34,21 +34,26 @@ pub use event::{ ChangeApplied, ChangeCancelled, ChangeProposed, CustodianSet, Deployed, DepositCapUpdated, DepositClaimed, DepositConvertedToExit, DepositRequested, EpochClosed, EpochFulfilled, EpochReconciled, Funded, NoticeSet, RedeemClaimed, RedeemRequested, RedemptionSurrendered, - WindDownActivated, WindDownClaimed, WindDownDelaySet, WindDownProposalCancelled, + SetupFinished, WindDownActivated, WindDownClaimed, WindDownDelaySet, WindDownProposalCancelled, WindDownProposed, WindDownRoundFinalized, }; -pub use governance::{VaultChange, VaultPendingChange}; +pub use governance::{VaultChange, VaultPendingChange, ADMIN_ACCEPT_LEDGERS}; pub use pricing::{DirectUnitPricing, PricingScheme}; pub use roles::VaultRoles; pub use settlement::EpochSettlement; pub use state::{DepositRequest, EpochInfo, EpochStatus, RedeemRequest}; pub use upgrade::{MAX_UPGRADE_DELAY, MIN_UPGRADE_DELAY}; -pub use wind_down::{WindDownInfo, WindDownPosition, WindDownStatus, MAX_WIND_DOWN_DELAY}; +pub use wind_down::{ + WindDownInfo, WindDownPosition, WindDownStatus, MAX_WIND_DOWN_DELAY, MIN_WIND_DOWN_DELAY, +}; /// Thirty days. Longer than any notice a real structure uses, short enough that /// setting it by mistake is survivable. pub const MAX_NOTICE_SECS: u64 = 30 * DAY_DURATION; +#[cfg(feature = "short_wind_down")] +soroban_sdk::contractmeta!(key = "build", val = "short wind-down; local tests only"); + fn role_holder(e: &Env, role: &Symbol) -> Option
{ if access_control::get_role_member_count(e, role) == 0 { return None; @@ -94,7 +99,6 @@ impl AsyncVault { state::set_epoch(e, FIRST_EPOCH, &epoch::open(0)); state::set_current_epoch(e, FIRST_EPOCH); upgradeable::set_schema_version(e, 1); - timelock::setup::finish::(e).unwrap_or_else(|r| governance::refuse(e, r)); } pub fn asset(e: &Env) -> Address { @@ -193,15 +197,23 @@ impl AsyncVault { .publish(e); } + /// Instant during setup; afterwards the custodian changes through the + /// timelock. #[only_admin] pub fn set_custodian(e: &Env, custodian: Address, caller: Address) { caller.require_auth(); + governance::require_setup_open(e); treasury::set_custodian(e, &custodian); } + /// Raising is instant. Lowering shortens the warning holders get before a + /// wind-down starts, so after setup it waits for the timelock. #[only_admin] pub fn set_wind_down_delay(e: &Env, secs: u64, caller: Address) { caller.require_auth(); + if secs < wind_down::delay(e) { + governance::require_setup_open(e); + } wind_down::set_delay(e, secs); } @@ -260,17 +272,29 @@ impl AsyncVault { wind_down::claimable(e, &holder) } + /// Raising is instant. Lowering shortens the wait every closed epoch gives + /// before it prices, so after setup it waits for the timelock. #[only_admin] pub fn set_notice(e: &Env, secs: u64, caller: Address) { caller.require_auth(); - if secs > MAX_NOTICE_SECS { - panic_with_error!(e, VaultError::NoticeTooLong); + if secs < state::notice(e) { + governance::require_setup_open(e); } - if secs > upgrade::delay(e) { - panic_with_error!(e, VaultError::NoticeAboveUpgradeDelay); - } - state::set_notice(e, secs); - NoticeSet { secs }.publish(e); + governance::set_notice(e, secs); + } + + /// Ends the window in which admin changes are instant, once the token, + /// the oracle, the manager role, the allowlist and the compliance binding + /// are wired to this vault. Deposits open from here on, and it never + /// reopens. + #[only_admin] + pub fn finish_setup(e: &Env, caller: Address) { + caller.require_auth(); + governance::finish_setup(e); + } + + pub fn setup_open(e: &Env) -> bool { + timelock::setup::is_open(e) } /// Queues a change behind the timelock and returns its id. @@ -393,9 +417,39 @@ impl AsyncVault { #[contractimpl(contracttrait)] 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); + access_control::grant_role(e, &account, &role, &caller); + } + + /// Instant for every role but `manager`: taking `treasury` or `guardian` + /// 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); + 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); + } + access_control::transfer_admin_role(e, &new_admin, live_until_ledger); + } + + /// Refused. A role admin could grant that role with no delay. + fn set_role_admin(e: &Env, _role: Symbol, _admin_role: Symbol) { + panic_with_error!(e, VaultError::UseTimelock); + } + /// Refused. A vault without an admin could never be unpaused, never set a - /// custodian, never rotate a role and never be upgraded again. - /// `transfer_admin_role` and `accept_admin_transfer` are the way out. + /// custodian, never rotate a role and never be upgraded again. The way out + /// is a hand-over: propose and apply an `Admin` change + /// (`transfer_admin_role` during setup), then the nominee calls + /// `accept_admin_transfer`. fn renounce_admin(e: &Env) { panic_with_error!(e, VaultError::AdminRequired); } diff --git a/contracts/async-vault/src/test/cancel.rs b/contracts/async-vault/src/test/cancel.rs index 6b31797d..b2ae39fa 100644 --- a/contracts/async-vault/src/test/cancel.rs +++ b/contracts/async-vault/src/test/cancel.rs @@ -100,7 +100,6 @@ fn the_same_investor_can_request_again_after_cancelling() { fn refundable_escrow_is_not_free_reserve() { let f = setup(); let inv = f.investor(1_000); - f.vault.set_custodian(&f.custodian, &f.admin); let before = f.vault.free_reserve(); f.vault.request_deposit(&inv, &400); @@ -187,7 +186,6 @@ fn a_dust_refund_never_takes_committed_money() { f.vault.fulfill_epoch(&epoch); // Move every free unit out, leaving only what the redeemer is owed. - f.vault.set_custodian(&f.custodian, &f.admin); let free = f.vault.free_reserve(); if free > 0 { f.vault.deploy_to_custodian(&f.treasury, &free); diff --git a/contracts/async-vault/src/test/compliance.rs b/contracts/async-vault/src/test/compliance.rs index f908835f..5d7b3fbe 100644 --- a/contracts/async-vault/src/test/compliance.rs +++ b/contracts/async-vault/src/test/compliance.rs @@ -73,6 +73,7 @@ fn a_holder_delisted_after_pricing_is_paid_through_a_wind_down() { ); f.vault.propose_wind_down(&f.admin); + f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); assert_eq!(f.vault.claim_deposit(&investor, &epoch), 0); assert_eq!(f.vault.finalize_wind_down_round(), 400); diff --git a/contracts/async-vault/src/test/controls.rs b/contracts/async-vault/src/test/controls.rs index 12104a76..6799488f 100644 --- a/contracts/async-vault/src/test/controls.rs +++ b/contracts/async-vault/src/test/controls.rs @@ -108,10 +108,17 @@ fn governance_can_replace_the_guardian() { let f = setup(); let next = Address::generate(&f.e); - f.vault - .grant_role(&next, &symbol_short!("guardian"), &f.admin); - f.vault - .revoke_role(&f.guardian, &symbol_short!("guardian"), &f.admin); + let grant = f.vault.propose_change( + &VaultChange::GrantRole(symbol_short!("guardian"), next.clone()), + &f.admin, + ); + let revoke = f.vault.propose_change( + &VaultChange::RevokeRole(symbol_short!("guardian"), f.guardian.clone()), + &f.admin, + ); + f.advance(MIN_UPGRADE_DELAY); + f.vault.apply_change(&grant, &f.admin); + f.vault.apply_change(&revoke, &f.admin); assert_eq!(f.vault.guardian(), Some(next.clone())); assert!(f.vault.try_pause(&f.guardian).is_err()); @@ -235,7 +242,11 @@ fn governance_hands_over_in_two_steps_instead() { let f = setup(); let next = Address::generate(&f.e); - f.vault.transfer_admin_role(&next, &1_000); + let id = f + .vault + .propose_change(&VaultChange::Admin(next.clone()), &f.admin); + f.advance(MIN_UPGRADE_DELAY); + f.vault.apply_change(&id, &f.admin); f.vault.accept_admin_transfer(); assert_eq!(f.vault.governance(), Some(next.clone())); diff --git a/contracts/async-vault/src/test/delisted.rs b/contracts/async-vault/src/test/delisted.rs index 0814b8bc..bb7430a2 100644 --- a/contracts/async-vault/src/test/delisted.rs +++ b/contracts/async-vault/src/test/delisted.rs @@ -108,6 +108,7 @@ fn in_a_wind_down_the_shares_are_credited_before_the_first_round() { let f = setup(); let (investor, epoch) = delisted_with_a_priced_deposit(&f); f.vault.propose_wind_down(&f.admin); + f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); assert_eq!(f.vault.claim_deposit(&investor, &epoch), 0); @@ -140,6 +141,7 @@ fn in_a_wind_down_the_shares_are_credited_after_the_first_round() { let f = setup(); let (investor, epoch) = delisted_with_a_priced_deposit(&f); f.vault.propose_wind_down(&f.admin); + f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); assert_eq!(f.vault.finalize_wind_down_round(), 400); @@ -156,6 +158,7 @@ fn a_credit_before_the_first_round_leaves_every_holder_their_share() { let other = f.holder(200); let (investor, epoch) = delisted_with_a_priced_deposit(&f); f.vault.propose_wind_down(&f.admin); + f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); f.vault.claim_deposit(&investor, &epoch); @@ -193,6 +196,7 @@ fn a_conversion_caught_by_a_wind_down_is_paid_from_the_rounds() { f.vault.propose_wind_down(&f.admin); f.vault.claim_deposit(&investor, &epoch); let open = f.vault.current_epoch(); + f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); assert_eq!(f.vault.cancel_redeem(&investor, &open), 200); @@ -222,6 +226,7 @@ fn a_redemption_surrendered_after_the_first_round_keeps_the_rounds_solvent() { f.vault.propose_wind_down(&f.admin); f.vault.claim_deposit(&investor, &epoch); let open = f.vault.current_epoch(); + f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); assert_eq!(f.vault.finalize_wind_down_round(), 800); @@ -240,6 +245,7 @@ fn a_delisted_holders_unpriced_redemption_joins_the_wind_down_when_cancelled() { let epoch = f.vault.request_redeem(&investor, &200); f.identity.allow(&investor, &false, &f.compliance); f.vault.propose_wind_down(&f.admin); + f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); assert_eq!(f.vault.cancel_redeem(&investor, &epoch), 200); diff --git a/contracts/async-vault/src/test/deposit_cap.rs b/contracts/async-vault/src/test/deposit_cap.rs index a0332b54..28ac415e 100644 --- a/contracts/async-vault/src/test/deposit_cap.rs +++ b/contracts/async-vault/src/test/deposit_cap.rs @@ -122,7 +122,6 @@ fn deployed_capital_and_multi_epoch_accounting() { f.vault.fulfill_epoch(&1); // Deploy 800 to custodian - f.vault.set_custodian(&f.custodian, &f.admin); f.vault.deploy_to_custodian(&f.treasury, &800); assert_eq!(f.vault.net_deployed(), 800); diff --git a/contracts/async-vault/src/test/governance.rs b/contracts/async-vault/src/test/governance.rs new file mode 100644 index 00000000..d3713d75 --- /dev/null +++ b/contracts/async-vault/src/test/governance.rs @@ -0,0 +1,392 @@ +use soroban_sdk::{ + testutils::{Events as _, MockAuth, MockAuthInvoke}, + Event as _, IntoVal, +}; +use stellar_access::role_transfer::RoleTransferError; + +use super::*; +use crate::{ChangeApplied, CustodianSet, ADMIN_ACCEPT_LEDGERS}; + +/// Proposes a change, waits one delay and applies it. +fn land(f: &Fixture, change: VaultChange) { + let id = f.vault.propose_change(&change, &f.admin); + f.advance(f.vault.upgrade_delay()); + f.vault.apply_change(&id, &f.admin); +} + +#[test] +fn the_custodian_changes_through_the_timelock() { + let f = setup(); + let next = Address::generate(&f.e); + + refused( + f.vault.try_set_custodian(&next, &f.admin), + VaultError::UseTimelock, + ); + + let id = f + .vault + .propose_change(&VaultChange::Custodian(next.clone()), &f.admin); + f.advance(MIN_UPGRADE_DELAY - 1); + refused( + f.vault.try_apply_change(&id, &f.admin), + VaultError::ChangeNotReady, + ); + f.advance(1); + f.vault.apply_change(&id, &f.admin); + + assert_eq!(f.vault.custodian(), Some(next)); +} + +#[test] +fn applying_a_custodian_change_publishes_both_events() { + let f = setup(); + let next = Address::generate(&f.e); + let change = VaultChange::Custodian(next.clone()); + let id = f.vault.propose_change(&change, &f.admin); + f.advance(MIN_UPGRADE_DELAY); + + f.vault.apply_change(&id, &f.admin); + + assert_eq!( + f.e.events().all().filter_by_contract(&f.vault.address), + [ + CustodianSet { custodian: next }.to_xdr(&f.e, &f.vault.address), + ChangeApplied { id, change }.to_xdr(&f.e, &f.vault.address), + ] + ); +} + +#[test] +fn raising_the_notice_is_instant_and_lowering_waits() { + let f = setup(); + + f.vault.set_notice(&DAY_DURATION, &f.admin); + assert_eq!(f.vault.notice(), DAY_DURATION); + + refused( + f.vault.try_set_notice(&3_600, &f.admin), + VaultError::UseTimelock, + ); + land(&f, VaultChange::Notice(3_600)); + + assert_eq!(f.vault.notice(), 3_600); +} + +#[test] +fn a_notice_change_still_respects_its_bounds() { + let f = setup(); + + refused( + f.vault + .try_propose_change(&VaultChange::Notice(MIN_UPGRADE_DELAY + 1), &f.admin), + VaultError::NoticeAboveUpgradeDelay, + ); +} + +#[test] +fn a_notice_change_checks_the_upgrade_delay_again_when_it_applies() { + let f = setup(); + land(&f, VaultChange::UpgradeDelay(30 * DAY_DURATION)); + let notice = f + .vault + .propose_change(&VaultChange::Notice(20 * DAY_DURATION), &f.admin); + let delay = f + .vault + .propose_change(&VaultChange::UpgradeDelay(MIN_UPGRADE_DELAY), &f.admin); + f.advance(30 * DAY_DURATION); + + // The delay falls below the notice the first change would set. + f.vault.apply_change(&delay, &f.admin); + + refused( + f.vault.try_apply_change(¬ice, &f.admin), + VaultError::NoticeAboveUpgradeDelay, + ); + assert_eq!(f.vault.notice(), 0); +} + +#[test] +fn raising_the_wind_down_delay_is_instant_and_lowering_waits() { + let f = setup(); + + f.vault + .set_wind_down_delay(&(2 * MIN_WIND_DOWN_DELAY), &f.admin); + refused( + f.vault + .try_set_wind_down_delay(&MIN_WIND_DOWN_DELAY, &f.admin), + VaultError::UseTimelock, + ); + land(&f, VaultChange::WindDownDelay(MIN_WIND_DOWN_DELAY)); + + assert_eq!(f.vault.wind_down_delay(), MIN_WIND_DOWN_DELAY); +} + +#[test] +fn a_wind_down_delay_below_the_minimum_cannot_be_proposed() { + let f = setup(); + + refused( + f.vault.try_propose_change( + &VaultChange::WindDownDelay(MIN_WIND_DOWN_DELAY - 1), + &f.admin, + ), + VaultError::WindDownDelayTooShort, + ); +} + +#[test] +fn grants_and_manager_revokes_wait_for_the_timelock() { + let f = setup(); + let next = Address::generate(&f.e); + + refused( + f.vault + .try_grant_role(&next, &symbol_short!("treasury"), &f.admin), + VaultError::UseTimelock, + ); + refused( + f.vault + .try_revoke_role(&f.manager, &symbol_short!("manager"), &f.admin), + VaultError::UseTimelock, + ); + + land( + &f, + VaultChange::GrantRole(symbol_short!("treasury"), next.clone()), + ); + assert!(f + .vault + .has_role(&next, &symbol_short!("treasury")) + .is_some()); +} + +#[test] +fn a_manager_revoke_lands_through_the_timelock() { + let f = setup(); + + land( + &f, + VaultChange::RevokeRole(symbol_short!("manager"), f.manager.clone()), + ); + + assert_eq!(f.vault.manager(), None); + assert!(f.vault.try_close_epoch(&f.manager).is_err()); +} + +#[test] +fn revoking_treasury_or_guardian_stays_instant() { + let f = setup(); + + f.vault + .revoke_role(&f.treasury, &symbol_short!("treasury"), &f.admin); + f.vault + .revoke_role(&f.guardian, &symbol_short!("guardian"), &f.admin); + + assert_eq!(f.vault.treasury(), None); + assert_eq!(f.vault.guardian(), None); +} + +#[test] +fn only_the_admin_revokes_a_role() { + let f = setup(); + let stranger = Address::generate(&f.e); + + assert!(f + .vault + .try_revoke_role(&f.treasury, &symbol_short!("treasury"), &stranger) + .is_err()); + assert!(f + .vault + .try_revoke_role(&f.treasury, &symbol_short!("treasury"), &f.guardian) + .is_err()); + assert_eq!(f.vault.treasury(), Some(f.treasury.clone())); +} + +#[test] +fn no_role_ever_gets_a_role_admin() { + for f in [setup_unfinished(), setup()] { + refused( + f.vault + .try_set_role_admin(&symbol_short!("treasury"), &symbol_short!("guardian")), + VaultError::UseTimelock, + ); + } +} + +#[test] +fn an_admin_hand_over_waits_but_cancelling_one_does_not() { + let f = setup(); + let next = Address::generate(&f.e); + + refused( + f.vault + .try_transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)), + VaultError::UseTimelock, + ); + + land(&f, VaultChange::Admin(next.clone())); + f.vault.transfer_admin_role(&next, &0); + + assert!(f.vault.try_accept_admin_transfer().is_err()); + assert_eq!(f.vault.governance(), Some(f.admin.clone())); +} + +#[test] +fn an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts() { + let f = setup(); + let next = Address::generate(&f.e); + let id = f + .vault + .propose_change(&VaultChange::Admin(next.clone()), &f.admin); + f.advance(MIN_UPGRADE_DELAY); + + // The admin authorises this call and nothing else: the hand-over inside it + // asks for no further signature. The entrypoint checks the admin and the + // caller, so the same authorisation appears twice, as simulation records it. + let apply = MockAuthInvoke { + contract: &f.vault.address, + fn_name: "apply_change", + args: (id, f.admin.clone()).into_val(&f.e), + sub_invokes: &[], + }; + f.e.mock_auths(&[ + MockAuth { + address: &f.admin, + invoke: &apply, + }, + MockAuth { + address: &f.admin, + invoke: &apply, + }, + ]); + f.vault.apply_change(&id, &f.admin); + + f.e.mock_all_auths(); + assert_eq!(f.vault.governance(), Some(f.admin.clone())); + f.vault.accept_admin_transfer(); + assert_eq!(f.vault.governance(), Some(next)); +} + +#[test] +fn the_hand_over_offer_is_open_on_its_last_ledger() { + let f = setup(); + let next = Address::generate(&f.e); + land(&f, VaultChange::Admin(next.clone())); + let offered_at = f.e.ledger().sequence(); + + f.e.ledger() + .with_mut(|l| l.sequence_number = offered_at + ADMIN_ACCEPT_LEDGERS); + f.vault.accept_admin_transfer(); + + assert_eq!(f.vault.governance(), Some(next)); +} + +#[test] +fn the_hand_over_offer_lasts_seven_days_of_ledgers() { + let f = setup(); + land(&f, VaultChange::Admin(Address::generate(&f.e))); + let offered_at = f.e.ledger().sequence(); + + f.e.ledger() + .with_mut(|l| l.sequence_number = offered_at + ADMIN_ACCEPT_LEDGERS + 1); + + assert_eq!( + f.vault.try_accept_admin_transfer(), + Err(Ok(RoleTransferError::NoPendingTransfer.into())) + ); + assert_eq!(f.vault.governance(), Some(f.admin.clone())); +} + +#[test] +fn a_change_shorter_than_a_raised_notice_never_lands() { + let f = setup(); + let next = Address::generate(&f.e); + let custodian = f + .vault + .propose_change(&VaultChange::Custodian(next), &f.admin); + let delay = f + .vault + .propose_change(&VaultChange::UpgradeDelay(30 * DAY_DURATION), &f.admin); + f.advance(MIN_UPGRADE_DELAY); + f.vault.apply_change(&delay, &f.admin); + + // The notice rises past the seven days the custodian change was given. + f.vault.set_notice(&(20 * DAY_DURATION), &f.admin); + f.advance(30 * DAY_DURATION); + + refused( + f.vault.try_apply_change(&custodian, &f.admin), + VaultError::UpgradeDelayBelowNotice, + ); + assert_eq!(f.vault.custodian(), Some(f.custodian.clone())); +} + +#[test] +fn the_deposit_cap_stays_instant_both_ways() { + let f = setup(); + + f.vault.set_deposit_cap(&Some(1_000), &f.admin); + assert_eq!(f.vault.deposit_cap(), Some(1_000)); + f.vault.set_deposit_cap(&Some(500), &f.admin); + assert_eq!(f.vault.deposit_cap(), Some(500)); + f.vault.set_deposit_cap(&None, &f.admin); + assert_eq!(f.vault.deposit_cap(), None); +} + +#[test] +fn a_pending_role_change_lands_after_a_pause() { + let f = setup(); + let next = Address::generate(&f.e); + let id = f.vault.propose_change( + &VaultChange::GrantRole(symbol_short!("guardian"), next.clone()), + &f.admin, + ); + f.vault.pause(&f.guardian); + f.advance(MIN_UPGRADE_DELAY); + + assert!(f.vault.try_apply_change(&id, &f.admin).is_err()); + + f.vault.unpause(&f.admin); + f.advance(MIN_UPGRADE_DELAY); + f.vault.apply_change(&id, &f.admin); + assert!(f + .vault + .has_role(&next, &symbol_short!("guardian")) + .is_some()); +} + +#[test] +fn nothing_timelocked_moves_during_a_wind_down_a_manager_revoke_included() { + let f = setup(); + let revoke = f.vault.propose_change( + &VaultChange::RevokeRole(symbol_short!("manager"), f.manager.clone()), + &f.admin, + ); + f.vault.propose_wind_down(&f.admin); + f.advance(MIN_WIND_DOWN_DELAY); + f.vault.activate_wind_down(); + + refused( + f.vault.try_apply_change(&revoke, &f.admin), + VaultError::WindDownActive, + ); + refused( + f.vault.try_propose_change( + &VaultChange::RevokeRole(symbol_short!("manager"), f.manager.clone()), + &f.admin, + ), + VaultError::WindDownActive, + ); + refused( + f.vault + .try_revoke_role(&f.manager, &symbol_short!("manager"), &f.admin), + VaultError::UseTimelock, + ); + assert_eq!(f.vault.manager(), Some(f.manager.clone())); + + // Taking an operational role away moves no value, so it stays instant. + f.vault + .revoke_role(&f.treasury, &symbol_short!("treasury"), &f.admin); + assert_eq!(f.vault.treasury(), None); +} diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index 56604860..b69d48d8 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -8,6 +8,7 @@ mod deposit; mod deposit_cap; mod epochs; mod events; +mod governance; mod lock_policy; mod multi_epoch; mod notice; @@ -15,6 +16,7 @@ mod oracle_pricing; mod pricing; mod redeem; mod settlement; +mod setup; mod supply; mod timing; mod treasury; @@ -42,7 +44,7 @@ pub(crate) use timelock::DAY_DURATION; pub(crate) use crate::{ AsyncVault, AsyncVaultClient, DirectUnitPricing, EpochStatus, PricingScheme, VaultChange, VaultError, VaultPendingChange, VaultRoles, WindDownStatus, MAX_NOTICE_SECS, MAX_UPGRADE_DELAY, - MAX_WIND_DOWN_DELAY, MIN_UPGRADE_DELAY, + MAX_WIND_DOWN_DELAY, MIN_UPGRADE_DELAY, MIN_WIND_DOWN_DELAY, }; fn wad(whole: i128) -> i128 { @@ -145,7 +147,6 @@ impl Fixture<'_> { } fn empty_reserve(&self) { - self.vault.set_custodian(&self.custodian, &self.admin); let free = self.vault.free_reserve(); if free > 0 { self.vault.deploy_to_custodian(&self.treasury, &free); @@ -196,20 +197,64 @@ fn setup<'a>() -> Fixture<'a> { setup_with_feed(open_feed()) } +/// A vault whose wiring is complete but whose own setup is still open: admin +/// changes are instant and deposits are refused. +fn setup_unfinished<'a>() -> Fixture<'a> { + miswired(Miswire::Nothing) +} + +/// A wiring step a test leaves out or gets wrong, to see `finish_setup` refuse. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Miswire { + Nothing, + TokenUnbound, + TokenBoundElsewhere, + TokenSetupOpen, + OracleUnbound, + OracleBoundElsewhere, + OracleSetupOpen, + NoManagerRole, + SecondManager, + NotAllowlisted, + ComplianceUnbound, +} + +/// A vault left in setup, wired except for `miswire`. +fn miswired<'a>(miswire: Miswire) -> Fixture<'a> { + build( + open_feed(), + |e, args| e.register(ShareToken, args), + miswire, + false, + ) +} + /// Builds the vault against a caller-chosen feed, so cooldown, freshness and the /// deviation cap can be exercised from a vault test rather than only an oracle one. fn setup_with_feed<'a>(feed: OracleConfig) -> Fixture<'a> { setup_with(feed, |e, args| e.register(ShareToken, args)) } +/// Builds a vault set whose setup has finished, around a token the caller +/// registers, so a test can run the token's built Wasm instead of its native +/// code. +fn setup_with<'a>( + feed: OracleConfig, + register_token: impl FnOnce(&Env, TokenArgs) -> Address, +) -> Fixture<'a> { + build(feed, register_token, Miswire::Nothing, true) +} + /// The share token's constructor arguments, in order. type TokenArgs = (String, String, Address, Address, Address, Address); -/// Builds the vault around a token the caller registers, so a test can run the -/// token's built Wasm instead of its native code. -fn setup_with<'a>( +/// Wires the set the way the deploy does, apart from `miswire`, then sets the +/// custodian and finishes the vault's setup when `finish` is set. +fn build<'a>( feed: OracleConfig, register_token: impl FnOnce(&Env, TokenArgs) -> Address, + miswire: Miswire, + finish: bool, ) -> Fixture<'a> { let e = Env::default(); e.mock_all_auths(); @@ -245,7 +290,9 @@ fn setup_with<'a>( ), ), ); - compliance.bind_token(&share.address, &admin); + if miswire != Miswire::ComplianceUnbound { + compliance.bind_token(&share.address, &admin); + } let oracle = NavOracleContractClient::new( &e, @@ -271,14 +318,50 @@ fn setup_with<'a>( }, ), ); - share.grant_role(&contract_id, &symbol_short!("manager"), &admin); + if miswire != Miswire::NoManagerRole { + share.grant_role(&contract_id, &symbol_short!("manager"), &admin); + } + if miswire == Miswire::SecondManager { + share.grant_role(&Address::generate(&e), &symbol_short!("manager"), &admin); + } // The vault holds escrowed shares, so returning them on cancellation goes // through the token's checked transfer. That verifies both sides, so the // vault has to be a recognised holder itself. - identity.allow(&contract_id, &true, &compliance_authority); + if miswire != Miswire::NotAllowlisted { + identity.allow(&contract_id, &true, &compliance_authority); + } + match miswire { + Miswire::TokenUnbound => {} + Miswire::TokenBoundElsewhere => { + share.bind_vault(®ister_with(&e, distinct_roles(&e)), &admin); + share.finish_setup(&admin); + } + Miswire::TokenSetupOpen => share.bind_vault(&contract_id, &admin), + _ => { + share.bind_vault(&contract_id, &admin); + share.finish_setup(&admin); + } + } + match miswire { + Miswire::OracleUnbound => {} + Miswire::OracleBoundElsewhere => { + oracle.bind_vault(®ister_with(&e, distinct_roles(&e)), &admin); + oracle.finish_setup(&admin); + } + Miswire::OracleSetupOpen => oracle.bind_vault(&contract_id, &admin), + _ => { + oracle.bind_vault(&contract_id, &admin); + oracle.finish_setup(&admin); + } + } + let vault = AsyncVaultClient::new(&e, &contract_id); + if finish { + vault.set_custodian(&custodian, &admin); + vault.finish_setup(&admin); + } Fixture { - vault: AsyncVaultClient::new(&e, &contract_id), + vault, share, identity, oracle, diff --git a/contracts/async-vault/src/test/multi_epoch.rs b/contracts/async-vault/src/test/multi_epoch.rs index a82c70ff..aab0a04e 100644 --- a/contracts/async-vault/src/test/multi_epoch.rs +++ b/contracts/async-vault/src/test/multi_epoch.rs @@ -266,7 +266,6 @@ fn the_treasury_cannot_deploy_while_anything_is_uncovered() { let f = setup(); let a = f.holder(200); - f.vault.set_custodian(&f.custodian, &f.admin); f.vault.request_redeem(&a, &200); let epoch = f.close_epoch(); f.attest(wad(3)); diff --git a/contracts/async-vault/src/test/setup.rs b/contracts/async-vault/src/test/setup.rs new file mode 100644 index 00000000..11500de5 --- /dev/null +++ b/contracts/async-vault/src/test/setup.rs @@ -0,0 +1,163 @@ +use soroban_sdk::{testutils::Events as _, Event as _}; +use stellar_access::role_transfer::RoleTransferError; + +use super::*; +use crate::SetupFinished; + +#[test] +fn a_deposit_before_setup_finishes_is_refused() { + let f = setup_unfinished(); + let investor = f.investor(1_000); + + refused( + f.vault.try_request_deposit(&investor, &100), + VaultError::SetupOpen, + ); + assert!(f.vault.setup_open()); +} + +#[test] +fn setup_finishes_once_the_wiring_is_complete() { + let f = setup_unfinished(); + + f.vault.finish_setup(&f.admin); + + assert!(!f.vault.setup_open()); + let investor = f.investor(1_000); + f.vault.request_deposit(&investor, &100); +} + +#[test] +fn finishing_setup_publishes_it() { + let f = setup_unfinished(); + + f.vault.finish_setup(&f.admin); + + assert_eq!( + f.e.events().all().filter_by_contract(&f.vault.address), + [SetupFinished {}.to_xdr(&f.e, &f.vault.address)] + ); +} + +#[test] +fn setup_never_finishes_twice() { + let f = setup(); + + refused(f.vault.try_finish_setup(&f.admin), VaultError::SetupClosed); +} + +#[test] +fn every_wiring_step_is_checked_before_setup_finishes() { + for miswire in [ + Miswire::TokenUnbound, + Miswire::TokenBoundElsewhere, + Miswire::TokenSetupOpen, + Miswire::OracleUnbound, + Miswire::OracleBoundElsewhere, + Miswire::OracleSetupOpen, + Miswire::NoManagerRole, + Miswire::SecondManager, + Miswire::NotAllowlisted, + Miswire::ComplianceUnbound, + ] { + let f = miswired(miswire); + + assert_eq!( + f.vault.try_finish_setup(&f.admin), + Err(Ok(VaultError::SetupIncomplete.into())), + "{miswire:?}" + ); + assert!(f.vault.setup_open(), "{miswire:?}"); + } +} + +#[test] +fn setup_cannot_finish_while_a_hand_over_is_pending() { + let f = setup_unfinished(); + let next = Address::generate(&f.e); + f.vault + .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)); + + refused( + f.vault.try_finish_setup(&f.admin), + VaultError::AdminTransferPending, + ); + + f.vault.transfer_admin_role(&next, &0); + f.vault.finish_setup(&f.admin); + assert!(f.vault.try_accept_admin_transfer().is_err()); + assert_eq!(f.vault.governance(), Some(f.admin.clone())); +} + +#[test] +fn an_expired_hand_over_does_not_hold_setup_open() { + let f = setup_unfinished(); + f.vault + .transfer_admin_role(&Address::generate(&f.e), &(f.e.ledger().sequence() + 10)); + f.e.ledger().with_mut(|l| l.sequence_number += 11); + + f.vault.finish_setup(&f.admin); + + assert!(!f.vault.setup_open()); + assert_eq!( + f.vault.try_accept_admin_transfer(), + Err(Ok(RoleTransferError::NoPendingTransfer.into())) + ); + assert_eq!(f.vault.governance(), Some(f.admin.clone())); +} + +#[test] +fn only_governance_finishes_setup() { + let f = setup_unfinished(); + let stranger = Address::generate(&f.e); + + f.e.set_auths(&[]); + assert!(f.vault.try_finish_setup(&stranger).is_err()); + assert!(f.vault.try_finish_setup(&f.manager).is_err()); + assert!(f.vault.setup_open()); +} + +#[test] +fn admin_changes_are_instant_during_setup() { + let f = setup_unfinished(); + let next = Address::generate(&f.e); + + f.vault.set_custodian(&f.custodian, &f.admin); + f.vault.set_notice(&3_600, &f.admin); + f.vault.set_notice(&0, &f.admin); + f.vault + .set_wind_down_delay(&(2 * MIN_WIND_DOWN_DELAY), &f.admin); + f.vault.set_wind_down_delay(&MIN_WIND_DOWN_DELAY, &f.admin); + f.vault + .grant_role(&next, &symbol_short!("guardian"), &f.admin); + f.vault + .revoke_role(&f.manager, &symbol_short!("manager"), &f.admin); + + assert_eq!(f.vault.custodian(), Some(f.custodian.clone())); + assert_eq!(f.vault.notice(), 0); + assert_eq!(f.vault.wind_down_delay(), MIN_WIND_DOWN_DELAY); + assert!(f + .vault + .has_role(&next, &symbol_short!("guardian")) + .is_some()); + assert_eq!(f.vault.manager(), None); +} + +#[test] +fn nothing_is_proposed_during_setup() { + let f = setup_unfinished(); + + refused( + f.vault + .try_propose_change(&VaultChange::Custodian(f.custodian.clone()), &f.admin), + VaultError::SetupOpen, + ); + refused( + f.vault.try_propose_change( + &VaultChange::WindDownDelay(MIN_WIND_DOWN_DELAY - 1), + &f.admin, + ), + VaultError::SetupOpen, + ); + assert_eq!(f.vault.pending_changes().len(), 0); +} diff --git a/contracts/async-vault/src/test/treasury.rs b/contracts/async-vault/src/test/treasury.rs index a6574ea5..4fd928b8 100644 --- a/contracts/async-vault/src/test/treasury.rs +++ b/contracts/async-vault/src/test/treasury.rs @@ -5,7 +5,6 @@ fn capital_makes_the_round_trip_to_the_custodian() { let f = setup(); let investor = f.investor(1_000); let epoch = f.vault.request_deposit(&investor, &1_000); - f.vault.set_custodian(&f.custodian, &f.admin); // Escrow stays refundable until the epoch prices, so none of it is free. assert_eq!(f.vault.free_reserve(), 0); @@ -31,7 +30,6 @@ fn returning_more_than_was_deployed_shows_as_negative_net() { let f = setup(); let investor = f.investor(1_000); let epoch = f.vault.request_deposit(&investor, &1_000); - f.vault.set_custodian(&f.custodian, &f.admin); f.close_epoch(); f.attest(wad(2)); f.vault.fulfill_epoch(&epoch); @@ -48,7 +46,6 @@ fn assets_owed_to_a_priced_redemption_cannot_be_deployed() { let f = setup(); let holder = f.holder(500); let epoch = f.vault.request_redeem(&holder, &500); - f.vault.set_custodian(&f.custodian, &f.admin); assert_eq!(f.fulfill_epoch(wad(2)), epoch); @@ -64,7 +61,6 @@ fn an_unpriced_redemption_does_not_reserve_anything_yet() { let f = setup(); let holder = f.holder(500); f.vault.request_redeem(&holder, &500); - f.vault.set_custodian(&f.custodian, &f.admin); assert_eq!(f.vault.free_reserve(), 1_000); assert_eq!(f.vault.deploy_to_custodian(&f.treasury, &1_000), 1_000); @@ -95,7 +91,6 @@ fn deploying_needs_the_treasury_role() { let f = setup(); let investor = f.investor(1_000); f.vault.request_deposit(&investor, &1_000); - f.vault.set_custodian(&f.custodian, &f.admin); assert!(f.vault.try_deploy_to_custodian(&f.manager, &100).is_err()); assert!(f.vault.try_deploy_to_custodian(&f.admin, &100).is_err()); @@ -104,17 +99,20 @@ fn deploying_needs_the_treasury_role() { #[test] fn deploying_without_a_custodian_is_rejected() { - let f = setup(); - let investor = f.investor(1_000); - f.vault.request_deposit(&investor, &1_000); + let f = setup_unfinished(); + let backer = f.investor(1_000); + f.vault.fund(&backer, &1_000); assert_eq!(f.vault.custodian(), None); - assert!(f.vault.try_deploy_to_custodian(&f.treasury, &100).is_err()); + refused( + f.vault.try_deploy_to_custodian(&f.treasury, &100), + VaultError::CustodianNotSet, + ); } #[test] fn setting_the_custodian_needs_the_admin_signature() { - let f = setup(); + let f = setup_unfinished(); f.e.set_auths(&[]); assert!(f.vault.try_set_custodian(&f.custodian, &f.admin).is_err()); @@ -130,7 +128,6 @@ fn deploying_a_non_positive_amount_is_rejected() { let f = setup(); let investor = f.investor(1_000); f.vault.request_deposit(&investor, &1_000); - f.vault.set_custodian(&f.custodian, &f.admin); assert!(f.vault.try_deploy_to_custodian(&f.treasury, &0).is_err()); assert!(f.vault.try_deploy_to_custodian(&f.treasury, &-1).is_err()); diff --git a/contracts/async-vault/src/test/upgrade.rs b/contracts/async-vault/src/test/upgrade.rs index 40065197..5a106bd6 100644 --- a/contracts/async-vault/src/test/upgrade.rs +++ b/contracts/async-vault/src/test/upgrade.rs @@ -309,6 +309,7 @@ fn the_vault_exposes_no_immediate_upgrade() { fn nothing_is_proposed_during_a_wind_down() { let f = setup(); f.vault.propose_wind_down(&f.admin); + f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); refused( @@ -323,6 +324,7 @@ fn a_change_standing_when_the_wind_down_starts_never_applies() { let f = setup(); let id = propose(&f, VaultChange::Upgrade(wasm(&f, 7))); f.vault.propose_wind_down(&f.admin); + f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); f.advance(MAX_UPGRADE_DELAY); @@ -483,8 +485,6 @@ mod with_wasm { let depositor = f.investor(500); assert_eq!(f.vault.request_deposit(&depositor, &500), epoch); - f.share.bind_vault(&f.vault.address, &f.admin); - f.share.finish_setup(&f.admin); let probe_hash = f.e.deployer().upload_contract_wasm(token_probe::WASM); let id = f .share diff --git a/contracts/async-vault/src/test/wind_down.rs b/contracts/async-vault/src/test/wind_down.rs index 2a0c6cfd..6ea7137e 100644 --- a/contracts/async-vault/src/test/wind_down.rs +++ b/contracts/async-vault/src/test/wind_down.rs @@ -6,10 +6,10 @@ use super::*; fn governance_sets_the_delay_within_the_maximum() { let f = setup(); - assert_eq!(f.vault.wind_down_delay(), 0); + assert_eq!(f.vault.wind_down_delay(), MIN_WIND_DOWN_DELAY); - f.vault.set_wind_down_delay(&(7 * 24 * 60 * 60), &f.admin); - assert_eq!(f.vault.wind_down_delay(), 7 * 24 * 60 * 60); + f.vault.set_wind_down_delay(&(14 * DAY_DURATION), &f.admin); + assert_eq!(f.vault.wind_down_delay(), 14 * DAY_DURATION); } #[test] @@ -20,7 +20,7 @@ fn the_delay_cannot_exceed_the_maximum() { .vault .try_set_wind_down_delay(&(MAX_WIND_DOWN_DELAY + 1), &f.admin) .is_err()); - assert_eq!(f.vault.wind_down_delay(), 0); + assert_eq!(f.vault.wind_down_delay(), MIN_WIND_DOWN_DELAY); } #[test] @@ -33,7 +33,7 @@ fn only_governance_sets_the_delay() { assert!(f.vault.try_set_wind_down_delay(&60, &f.manager).is_err()); assert!(f.vault.try_set_wind_down_delay(&60, &f.treasury).is_err()); assert!(f.vault.try_set_wind_down_delay(&60, &f.guardian).is_err()); - assert_eq!(f.vault.wind_down_delay(), 0); + assert_eq!(f.vault.wind_down_delay(), MIN_WIND_DOWN_DELAY); } #[test] @@ -146,18 +146,36 @@ fn activating_without_a_proposal_is_refused() { } #[test] -fn a_zero_delay_activates_immediately() { +fn an_unset_delay_still_waits_the_minimum() { let f = setup(); f.vault.propose_wind_down(&f.admin); - f.vault.activate_wind_down(); + f.advance(MIN_WIND_DOWN_DELAY - 1); + refused( + f.vault.try_activate_wind_down(), + VaultError::WindDownDelayNotElapsed, + ); + f.advance(1); + f.vault.activate_wind_down(); assert_eq!(f.vault.wind_down().unwrap().status, WindDownStatus::Active); } +#[test] +fn a_delay_below_the_minimum_is_refused() { + let f = setup_unfinished(); + + refused( + f.vault + .try_set_wind_down_delay(&(MIN_WIND_DOWN_DELAY - 1), &f.admin), + VaultError::WindDownDelayTooShort, + ); +} + /// Proposes, waits and activates. Returns nothing; the vault is active after. fn wind_down_now(f: &Fixture) { f.vault.propose_wind_down(&f.admin); + f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); } @@ -188,7 +206,6 @@ fn an_active_wind_down_prices_nothing_and_closes_no_epoch() { #[test] fn an_active_wind_down_sends_nothing_to_the_custodian() { let f = setup(); - f.vault.set_custodian(&f.custodian, &f.admin); let investor = f.investor(1_000); f.vault.request_deposit(&investor, &400); f.fulfill_epoch(wad(2)); @@ -200,7 +217,6 @@ fn an_active_wind_down_sends_nothing_to_the_custodian() { #[test] fn funding_stays_open_during_a_wind_down() { let f = setup(); - f.vault.set_custodian(&f.custodian, &f.admin); let backer = f.investor(1_000); wind_down_now(&f); @@ -685,6 +701,7 @@ fn a_later_round_needs_the_units_an_earlier_round_left_in_owed() { let b = f.holder(1); f.empty_reserve(); f.vault.propose_wind_down(&f.admin); + f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); f.vault.fund(&f.custodian, &1); @@ -708,6 +725,7 @@ fn after_every_holder_claims_owed_keeps_at_most_one_unit_per_holder() { let holders = [f.holder(1), f.holder(2), f.holder(4)]; f.empty_reserve(); f.vault.propose_wind_down(&f.admin); + f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); f.vault.fund(&f.custodian, &5); f.vault.finalize_wind_down_round(); @@ -739,6 +757,7 @@ proptest! { let backer = f.investor(total_shares + 10_000); f.empty_reserve(); f.vault.propose_wind_down(&f.admin); + f.advance(MIN_WIND_DOWN_DELAY); f.vault.activate_wind_down(); // Funds exactly the snapshot, so the first round always credits diff --git a/contracts/async-vault/src/wind_down.rs b/contracts/async-vault/src/wind_down.rs index 1427cddf..651ab517 100644 --- a/contracts/async-vault/src/wind_down.rs +++ b/contracts/async-vault/src/wind_down.rs @@ -16,6 +16,14 @@ use crate::treasury; /// governance cannot set a delay the vault would never outlive. pub const MAX_WIND_DOWN_DELAY: u64 = 90 * timelock::DAY_DURATION; +/// Seven days: holders always get a week's warning before a wind-down starts. +/// It is also the delay when none was set. +#[cfg(not(feature = "short_wind_down"))] +pub const MIN_WIND_DOWN_DELAY: u64 = 7 * timelock::DAY_DURATION; +/// The local harness build activates a wind-down at once. +#[cfg(feature = "short_wind_down")] +pub const MIN_WIND_DOWN_DELAY: u64 = 0; + #[contracttype] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum WindDownStatus { @@ -42,13 +50,21 @@ pub struct WindDownPosition { } pub(crate) fn delay(e: &Env) -> u64 { - storage::get_instance(e, &DataKey::WindDownDelay).unwrap_or(0) + storage::get_instance(e, &DataKey::WindDownDelay).unwrap_or(MIN_WIND_DOWN_DELAY) } -pub(crate) fn set_delay(e: &Env, secs: u64) { +#[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); storage::set_instance(e, &DataKey::WindDownDelay, &secs); WindDownDelaySet { secs }.publish(e); } diff --git a/environments.toml b/environments.toml index 514b39a6..41acc24a 100644 --- a/environments.toml +++ b/environments.toml @@ -51,8 +51,14 @@ constructor_args = '--asset share_token --share_token share_token --oracle nav_o # The vault needs the share token's manager role and needs allowlisting on the # identity verifier, and both target other contracts while requiring the vault's # own address, which only exists after it deploys last. The deploy in -# scripts/harness performs and verifies both. A deployment made only from -# this file can take deposits but can neither deliver shares nor return them. +# scripts/harness performs and verifies both. +# +# Setup cannot finish from this file either. The vault's `finish_setup` checks +# the token and oracle bindings, the manager role, the allowlist and the +# compliance binding, which only the harness deploy wires. A deploy made only +# from this file leaves the token's, the oracle's and the vault's setup open, so +# its vault refuses deposits with `SetupOpen`. The harness deploy closes all +# three. ### Staging environment configuration diff --git a/scripts/harness/deploy.ts b/scripts/harness/deploy.ts index 5d6bc496..a948bc4b 100644 --- a/scripts/harness/deploy.ts +++ b/scripts/harness/deploy.ts @@ -5,6 +5,7 @@ import { pathToFileURL } from "node:url" import { Asset, rpc, type Keypair } from "@stellar/stellar-sdk" import { basicNodeSigner } from "@stellar/stellar-sdk/contract" +import type * as AsyncVaultModule from "../../app-lib/clients/async_vault/dist/index.js" import type * as ComplianceModule from "../../app-lib/clients/compliance/dist/index.js" import type * as IdentityVerifierModule from "../../app-lib/clients/identity_verifier/dist/index.js" import type * as NavOracleModule from "../../app-lib/clients/nav_oracle/dist/index.js" @@ -212,6 +213,33 @@ async function grantAndVerify( console.log("nav_oracle bound to the vault, setup finished") } +// The custodian is set while setup is open; afterwards it changes only through +// the timelock. finish_setup refuses unless every wiring step above landed. +async function finishVaultSetup( + net: Network, + accounts: Accounts, + contracts: Record, +): Promise { + const governance = accounts.governance + const caller = governance.publicKey() + const { Client: AsyncVault } = (await import( + clientEntry("async_vault") + )) as typeof AsyncVaultModule + const vault = new AsyncVault( + clientOptions(net, contracts.async_vault, governance), + ) + await ( + await vault.set_custodian({ + custodian: accounts.custodian.publicKey(), + caller, + }) + ).signAndSend() + await (await vault.finish_setup({ caller })).signAndSend() + if ((await vault.setup_open()).result) + throw new Error("the vault's setup did not finish") + console.log("vault setup finished") +} + async function main(): Promise { requireWasm() const net = network() @@ -234,6 +262,7 @@ async function main(): Promise { console.log("clients generated") await grantAndVerify(net, accounts, contracts) + await finishVaultSetup(net, accounts, contracts) const record: Deployment = { network: net.name, diff --git a/scripts/harness/guards.test.ts b/scripts/harness/guards.test.ts index 3f51e76a..36dbeef3 100644 --- a/scripts/harness/guards.test.ts +++ b/scripts/harness/guards.test.ts @@ -1,5 +1,5 @@ import { setTimeout as sleep } from "node:timers/promises" -import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { beforeAll, describe, expect, it } from "vitest" import { pay, trustline } from "./lib/accounts.js" import { assetsFor, price, sharesFor, units } from "./lib/amounts.js" @@ -65,12 +65,6 @@ beforeAll(async () => { }), ) } - await send( - vault(c, governance).set_custodian({ - custodian: custodian.publicKey(), - caller: governance.publicKey(), - }), - ) }) describe("an account with no role", () => { @@ -90,9 +84,14 @@ describe("an account with no role", () => { ) }) - it("cannot set the custodian", async () => { + it("cannot change the custodian", async () => { await expect( - send(vault(c, outsider).set_custodian({ custodian: who, caller: who })), + send( + vault(c, outsider).propose_change({ + change: { tag: "Custodian", values: [who] }, + caller: who, + }), + ), ).rejects.toThrow(/signatures/) }) @@ -302,6 +301,40 @@ describe("the oracle after setup", () => { }) }) +describe("the vault after setup", () => { + const caller = () => governance.publicKey() + + it("governance cannot change the custodian at once", async () => { + const v = vault(c, governance) + expectRefused( + await v.set_custodian({ + custodian: outsider.publicKey(), + caller: caller(), + }), + v, + "UseTimelock", + ) + expect((await reader.custodian()).result).toBe(custodian.publicKey()) + }) + + it("queues a custodian change behind its delay", async () => { + const v = vault(c, governance) + const id = await send( + v.propose_change({ + change: { tag: "Custodian", values: [outsider.publicKey()] }, + caller: caller(), + }), + ) + expectRefused( + await v.apply_change({ id, caller: caller() }), + v, + "ChangeNotReady", + ) + await send(v.cancel_change({ id, caller: caller() })) + expect((await v.pending_changes()).result).toEqual([]) + }) +}) + describe("invalid amounts and duplicate requests in an epoch", () => { it("rejects zero deposit amount", async () => { const v = vault(c, first) @@ -690,15 +723,6 @@ describe("the notice period and valuation age rules", () => { ) }) - afterAll(async () => { - await send( - vault(c, governance).set_notice({ - secs: 0n, - caller: governance.publicKey(), - }), - ) - }) - it("records priceable_at at close and holds it through the notice", async () => { await send( vault(c, first).request_deposit({ @@ -766,4 +790,14 @@ describe("the notice period and valuation age rules", () => { } expect(await fulfill(epochB)).toBe(secondPrice) }) + + it("governance cannot lower the notice at once", async () => { + const v = vault(c, governance) + expectRefused( + await v.set_notice({ secs: 0n, caller: governance.publicKey() }), + v, + "UseTimelock", + ) + expect((await reader.notice()).result).toBe(noticeSecs) + }) }) diff --git a/scripts/harness/happy-path.test.ts b/scripts/harness/happy-path.test.ts index 43fe0ec0..415ae3d2 100644 --- a/scripts/harness/happy-path.test.ts +++ b/scripts/harness/happy-path.test.ts @@ -22,15 +22,7 @@ const holders = [ c.keys["holder-3"], ] as const const [first, second, canceller] = holders -const { - governance, - compliance, - custodian, - outsider, - issuer, - manager, - treasury, -} = c.keys +const { compliance, custodian, outsider, issuer, manager, treasury } = c.keys let decimals = 7 const usdc = (whole: number) => units(whole, decimals) @@ -313,14 +305,9 @@ describe("treasury", () => { const deployed = () => usdc(150) let owed = 0n - it("governance sets the custodian", async () => { - await send( - vault(c, governance).set_custodian({ - custodian: custodian.publicKey(), - caller: governance.publicKey(), - }), - ) + it("the deploy set the custodian before setup closed", async () => { expect((await reader.custodian()).result).toBe(custodian.publicKey()) + expect((await reader.setup_open()).result).toBe(false) }) it("the treasury deploys part of the reserve and the free reserve falls by exactly that amount", async () => { diff --git a/scripts/harness/seed.ts b/scripts/harness/seed.ts index a6a546d7..4bb16227 100644 --- a/scripts/harness/seed.ts +++ b/scripts/harness/seed.ts @@ -121,7 +121,6 @@ async function main(): Promise { const c: Connected = connect() const { - governance, manager, treasury, custodian, @@ -162,20 +161,13 @@ async function main(): Promise { await ensureAllowlist(c, key, name) } - // Ensure vault custodian is configured const currentCustodian = (await reader.custodian()).result if (!currentCustodian) { - console.log(" + setting vault custodian...") - await send( - vault(c, governance).set_custodian({ - custodian: custodian.publicKey(), - caller: governance.publicKey(), - }), + throw new Error( + "the vault has no custodian; the deploy sets it before setup closes", ) - console.log(` ✓ custodian configured: ${custodian.publicKey()}`) - } else { - console.log(` • vault custodian already configured: ${currentCustodian}`) } + console.log(` • vault custodian configured: ${currentCustodian}`) // 2. Lifecycle Seeding console.log("\nStep 2: Processing Vault Lifecycle...") From f095d656b27b236470b6b9183a6cd04261392ebd Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:20:49 -0300 Subject: [PATCH 2/3] docs: put the vault's admin behind the timelock --- README.md | 13 ++++---- docs/AUTHORIZATION_MATRIX.md | 28 +++++++++++------ docs/TESTING.md | 14 ++++++++- docs/kit-spec.md | 42 ++++++++++++++++--------- docs/strata-product-and-architecture.md | 11 ++++--- 5 files changed, 72 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 1353f7bc..e648d72e 100644 --- a/README.md +++ b/README.md @@ -112,18 +112,19 @@ Output of a real run from a fresh clone. Contract IDs differ on every run. deploying to local (http://localhost:8000/rpc) funded 13 accounts -asset contract CDPQAISI45SS2INROW6YGBUJUZ23IO2IQ4OW4YN3TFCX6RDR4I4DGC45 -compliance CASCZWH44CPGC26FIEBDZKXHWLXEPF2SLCHBTIOMAH33TWA76P3V2QAW -identity_verifier CCGG7IHN4D5P43DA32N2SVQM62ZSPBYY45AUY7AIO6U4TVKWP7X7C4CW -share_token CC3OIIFWYZPRGYWX4GS2NTHLOD3OHMIZY6SAWD3W4LKZNU4ZMLZNSOP2 -nav_oracle CD4MEW2NM4WDXBGA4V53RCQTMXXKXIWXLBAXX6U4XYER343T7AEU52WP -async_vault CBUOZYNWDS3BJ7W4JCZOHPKMWAOQLKPEOB7IY6GTPHJGQU7Q6ZHS2VPU +asset contract CCAE3ZUI4B2LSOF7HBS4F35SOHYDG7FOLSQ4DPKAS7YU3JB7IN2U2WOR +compliance CAFIN446IPMTL7E2RMHSSDFS5BRYKU6BJS7L7DBIDHLUDGPWEHVOSE2D +identity_verifier CCNJIIWUXN76GHMGNJW4WIDWYOLBGL6JPUT2J5FU2F6T74VUYNT6M7PW +share_token CCXMRDX67EUA563K3Z7BYJU5HM2PACTYQRHCGI7AXKZZDKEVZNPBYMDT +nav_oracle CDNW5MROHAB2ULG3ODWEVSMSVBTOAHDATES74HYAEZIPQCJB4HUB7KXD +async_vault CC2GSF7NZYFSUXX6UP7FFSIDXCKJV7SLN2HS7SD4XO42WHPXHQW3SJQM clients generated vault holds share_token manager role share_token bound to the vault, setup finished vault is allowlisted share_token bound to compliance nav_oracle bound to the vault, setup finished +vault setup finished wrote deployed.local.json ``` diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index 937ba7bd..fa70b0ff 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -35,9 +35,10 @@ In `scripts/harness/deploy.ts` and automated deployment scripts, the separation - The vault's constructor receives `roles.manager = accounts.manager.publicKey()`. - The share token's constructor grants `compliance` to the compliance key and grants `manager` to no one; the identity verifier is constructed with the compliance key as admin. - The share token role is granted to the deployed vault contract: `token.grant_role({ account: asyncVault.address, role: "manager", caller: governance })`. -- After granting, the script checks `token.has_role({ account: vault, role: "manager" })` and stops if the vault does not hold the role. It does not check that no other account holds it. +- After granting, the script checks `token.has_role({ account: vault, role: "manager" })` and stops if the vault does not hold the role. That no other account holds it is checked by the vault's `finish_setup` below. - It then calls `token.bind_vault({ vault, caller: governance })` and `token.finish_setup({ caller: governance })`, and stops if `token.setup_open()` still reads true. From then on the token's admin changes wait for the vault's timelock (§5.2). -- Last, it calls `oracle.bind_vault({ vault, caller: governance })` and `oracle.finish_setup({ caller: governance })`, and stops if `oracle.setup_open()` still reads true. From then on the oracle's admin changes wait for the vault's timelock (§5.3). +- It calls `oracle.bind_vault({ vault, caller: governance })` and `oracle.finish_setup({ caller: governance })`, and stops if `oracle.setup_open()` still reads true. From then on the oracle's admin changes wait for the vault's timelock (§5.3). +- Last, it calls `vault.set_custodian({ custodian, caller: governance })` and `vault.finish_setup({ caller: governance })`, and stops if `vault.setup_open()` still reads true. `finish_setup` refuses with `SetupIncomplete` unless the token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the vault passes the identity check and the token is bound to its compliance contract. From then on the vault takes deposits and its admin changes wait for its timelock (§5.1). --- @@ -62,7 +63,7 @@ The following entrypoints are open to any caller on purpose: | **`AsyncVault`** | `activate_wind_down` | Open (Any caller) | Once governance announces wind-down and the timelock expires, anyone (including investors) can trigger activation. This ensures an operator cannot propose wind-down to block deposits and then stall activation indefinitely. | | **`AsyncVault`** | `finalize_wind_down_round` | Open (Any caller) | Distributing returned capital pro-rata across the supply snapshot is deterministic. Making finalisation open ensures investors or keepers can trigger payout rounds immediately when funds arrive. | | **`AsyncVault`** | `claim_wind_down` | `holder.require_auth()` | The holder signs their own claim; a keeper cannot claim for them. The payout goes to `holder`. | -| **`AsyncVault`** | `request_deposit` | `from.require_auth()` | Investor entrypoint. Deposits escrowed cash; guarded by `when_not_paused` and not winding down. | +| **`AsyncVault`** | `request_deposit` | `from.require_auth()` | Investor entrypoint. Deposits escrowed cash; guarded by `when_not_paused`, not winding down, and refused with `SetupOpen` until the vault's setup finishes, so every deposit is covered by the timelock. | | **`AsyncVault`** | `claim_deposit` | `caller.require_auth()` | Investor entrypoint. Claims shares once epoch fulfilled; gated by SEP-57 receiver check on `mint`. | | **`AsyncVault`** | `cancel_deposit` | `from.require_auth()` | Investor exit before epoch pricing. Single-step refund. | | **`AsyncVault`** | `request_redeem` | `from.require_auth()` | Investor exit. Always open, even during pause. | @@ -75,7 +76,9 @@ The following entrypoints are open to any caller on purpose: ### 5.1 AsyncVault (`contracts/async-vault`) -Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs.rs`](../contracts/async-vault/src/test/epochs.rs), [`notice.rs`](../contracts/async-vault/src/test/notice.rs), [`treasury.rs`](../contracts/async-vault/src/test/treasury.rs), [`upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs) and [`wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs) in [`contracts/async-vault/src/test/`](../contracts/async-vault/src/test/). +The vault's `admin` is governance. Until `finish_setup`, admin changes are instant and the vault takes no deposit (`SetupOpen`). Setup finishes once the wiring is complete and no admin hand-over offered during setup is pending, and it never reopens. After setup, the following wait for the timelock through `propose_change`/`apply_change`: the custodian, grants, revoking `manager`, admin hand-over, lowering the notice or the wind-down delay, and upgrades. Raising the notice or the wind-down delay, the deposit cap, `pause`/`unpause` and revoking `treasury` or `guardian` stay instant. During an active wind-down nothing timelocked is proposed or applied, a `manager` revoke included. + +Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs.rs`](../contracts/async-vault/src/test/epochs.rs), [`governance.rs`](../contracts/async-vault/src/test/governance.rs), [`notice.rs`](../contracts/async-vault/src/test/notice.rs), [`setup.rs`](../contracts/async-vault/src/test/setup.rs), [`treasury.rs`](../contracts/async-vault/src/test/treasury.rs), [`upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs) and [`wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs) in [`contracts/async-vault/src/test/`](../contracts/async-vault/src/test/). | Entrypoint | Access Control / Caller Auth | State & Precondition Guards | Tested Refusal (Test Name) | |---|---|---|---| @@ -83,16 +86,21 @@ Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs. | `deploy_to_custodian` | `#[only_role(caller, "treasury")]` | `assets <= free_reserve`, not winding down | `test::treasury::deploying_needs_the_treasury_role` | | `pause` | `#[only_role(caller, "guardian")]` | Contract not already paused | `test::controls::only_the_guardian_pauses` | | `unpause` | `#[only_admin]`, `caller.require_auth()` | Contract must be paused | `test::controls::the_guardian_pauses_but_only_governance_unpauses` | -| `set_custodian` | `#[only_admin]`, `caller.require_auth()` | None | `test::treasury::setting_the_custodian_needs_the_admin_signature` | -| `set_notice` | `#[only_admin]`, `caller.require_auth()` | `secs <= MAX_NOTICE_SECS`, `secs <= upgrade_delay` | `test::notice::setting_the_notice_needs_governance_authorisation` | -| `set_wind_down_delay` | `#[only_admin]`, `caller.require_auth()` | `secs <= MAX_WIND_DOWN_DELAY`, not active | `test::wind_down::only_governance_sets_the_delay` | +| `set_custodian` | `#[only_admin]`, `caller.require_auth()` | Instant during setup; `UseTimelock` after | `test::governance::the_custodian_changes_through_the_timelock`, `test::treasury::setting_the_custodian_needs_the_admin_signature` | +| `set_notice` | `#[only_admin]`, `caller.require_auth()` | `secs <= MAX_NOTICE_SECS`, `secs <= upgrade_delay`. Raising is instant; lowering is `UseTimelock` after setup | `test::governance::raising_the_notice_is_instant_and_lowering_waits`, `test::notice::setting_the_notice_needs_governance_authorisation` | +| `set_wind_down_delay` | `#[only_admin]`, `caller.require_auth()` | `MIN_WIND_DOWN_DELAY <= secs <= MAX_WIND_DOWN_DELAY`. Raising is instant; lowering is `UseTimelock` after setup | `test::governance::raising_the_wind_down_delay_is_instant_and_lowering_waits`, `test::governance::a_wind_down_delay_below_the_minimum_cannot_be_proposed`, `test::wind_down::only_governance_sets_the_delay` | +| `set_deposit_cap` | `#[only_admin]`, `caller.require_auth()` | Not negative. Instant both ways, also after setup | `test::governance::the_deposit_cap_stays_instant_both_ways` | +| `finish_setup` | `#[only_admin]`, `caller.require_auth()` | Once. The token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, passes `verify_identity`, and the token is in its compliance contract's `linked_tokens`; no admin hand-over offered during setup is pending | `test::setup::every_wiring_step_is_checked_before_setup_finishes`, `test::setup::setup_cannot_finish_while_a_hand_over_is_pending`, `test::setup::setup_never_finishes_twice`, `test::setup::only_governance_finishes_setup` | +| `grant_role` / `transfer_admin_role` | Admin | Instant during setup, `UseTimelock` after. A zero `live_until_ledger` cancels instantly | `test::governance::grants_and_manager_revokes_wait_for_the_timelock`, `test::governance::an_admin_hand_over_waits_but_cancelling_one_does_not` | +| `revoke_role` | Admin | `treasury`, `guardian`: instant. `manager`: instant during setup, `UseTimelock` after | `test::governance::grants_and_manager_revokes_wait_for_the_timelock` (instant case: `test::governance::revoking_treasury_or_guardian_stays_instant`; caller check: `test::governance::only_the_admin_revokes_a_role`) | +| `set_role_admin` | — | Always refused (`UseTimelock`) | `test::governance::no_role_ever_gets_a_role_admin` | | `propose_wind_down` | `#[only_admin]`, `caller.require_auth()` | No proposal standing, not active | `test::wind_down::only_governance_proposes` | | `cancel_wind_down_proposal` | `#[only_admin]`, `caller.require_auth()` | Must be proposed, not active | `test::wind_down::governance_cancels_a_proposal_before_activation` | -| `propose_change` | `#[only_admin]`, `caller.require_auth()` | Not winding down, one code change at a time, an `UpgradeDelay` in `[MIN, MAX]` and `>= notice` | `test::upgrade::only_governance_proposes_and_cancels`, `test::upgrade::nothing_is_proposed_during_a_wind_down` | +| `propose_change` | `#[only_admin]`, `caller.require_auth()` | Not winding down, setup finished, one code change at a time, an `UpgradeDelay` in `[MIN, MAX]` and `>= notice`, a `Notice` within its bounds, a `WindDownDelay` in `[MIN_WIND_DOWN_DELAY, MAX_WIND_DOWN_DELAY]` | `test::upgrade::only_governance_proposes_and_cancels`, `test::upgrade::nothing_is_proposed_during_a_wind_down`, `test::setup::nothing_is_proposed_during_setup` | | `cancel_change` | `#[only_admin]`, `caller.require_auth()` | The change must be pending | `test::upgrade::only_governance_proposes_and_cancels` | -| `apply_change` | `#[only_admin]`, `caller.require_auth()` | Eta passed, not paused, not winding down, delay `>= notice` | `test::upgrade::only_governance_applies` | +| `apply_change` | `#[only_admin]`, `caller.require_auth()` | Eta passed, not paused, not winding down, delay `>= notice`; an `UpgradeDelay`, `Notice` or `WindDownDelay` change is checked against its bounds again | `test::upgrade::only_governance_applies`, `test::governance::a_change_shorter_than_a_raised_notice_never_lands`, `test::governance::nothing_timelocked_moves_during_a_wind_down_a_manager_revoke_included` | | `pending_changes` | Open view | None | None (view) | -| `renounce_admin` | Refused always (`VaultError::AdminRequired`) | None | `test::controls::governance_cannot_renounce_itself_out_of_the_vault` | +| `renounce_admin` | Refused always (`VaultError::AdminRequired`) | None | `test::controls::governance_cannot_renounce_itself_out_of_the_vault`; `test::controls::governance_hands_over_in_two_steps_instead`; `test::governance::an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts`. Hand over by proposing and applying an `Admin` change (`transfer_admin_role` during setup), then the nominee calls `accept_admin_transfer`. | ### 5.2 ShareToken (`contracts/share-token`) diff --git a/docs/TESTING.md b/docs/TESTING.md index 4cc71dc0..81191b58 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -27,6 +27,12 @@ CARGO_TARGET_DIR=target/upgrade-probe stellar contract build --package nav-oracl SOROBAN_SDK_BUILD_SYSTEM_SUPPORTS_SPEC_SHAKING_V2=1 cargo test -p async-vault -p nav-oracle --features upgrade_wasm with_wasm ``` +The `short_wind_down` feature builds a vault whose wind-down minimum is zero, +for a local harness only; its WASM carries the meta entry `build = short +wind-down; local tests only`. Check that it compiles with +`cargo check -p async-vault --features short_wind_down`. Do not run the test +suite under it: tests that subtract from the minimum assume seven days. + ## Storage and TTL The contracts read and write through the shared helpers in @@ -75,6 +81,7 @@ nothing. | Share token: only the admin binds the vault, finishes setup and runs the timelock | [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs) | `only_the_admin_binds_and_finishes`, `only_the_admin_proposes_cancels_and_applies` | | Oracle: only the attester posts, only the guardian pauses the NAV | [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | `attest_is_role_gated`, `pausing_the_nav_is_limited_to_the_guardian`, `only_admin_can_set_config` | | Oracle: only the admin binds the vault, finishes setup, runs the timelock and revokes a role | [`contracts/nav-oracle/src/test_governance.rs`](../contracts/nav-oracle/src/test_governance.rs) | `only_the_admin_binds_and_finishes`, `only_the_admin_proposes_cancels_and_applies`, `only_the_admin_revokes_a_role` | +| Vault: only the admin finishes setup; after it, the custodian, grants, `manager` revokes, hand-overs and a lower notice or wind-down delay go through the timelock, while revoking the treasury or the guardian stays instant | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs), [`contracts/async-vault/src/test/governance.rs`](../contracts/async-vault/src/test/governance.rs) | `only_governance_finishes_setup`, `the_custodian_changes_through_the_timelock`, `grants_and_manager_revokes_wait_for_the_timelock`, `revoking_treasury_or_guardian_stays_instant`, `only_the_admin_revokes_a_role` | | Identity verifier: only the admin edits the allowlist | [`contracts/identity-verifier/src/test.rs`](../contracts/identity-verifier/src/test.rs) | `only_admin_can_allow` | ## Ledger time and sequence @@ -91,6 +98,7 @@ so time rules and TTL can be reached in one test. | Upgrade and wind-down timelocks follow the ledger clock | [`contracts/async-vault/src/test/upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs), [`contracts/async-vault/src/test/wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs) | `applying_before_the_eta_is_refused`, `a_pause_freezes_the_upgrade_timelock_clock`, `governance_proposes_and_anyone_activates_after_the_delay` | | The share token's changes wait on the vault's clock: a vault pause moves the eta, a wind-down refuses them | [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs) | `a_grant_lands_one_vault_delay_after_it_was_proposed`, `a_vault_pause_moves_the_eta_and_holds_the_change`, `nothing_is_proposed_or_applied_while_the_vault_winds_down` | | The oracle's changes wait on the vault's clock the same way | [`contracts/nav-oracle/src/test_governance.rs`](../contracts/nav-oracle/src/test_governance.rs) | `a_loosened_config_lands_through_the_timelock`, `a_vault_pause_moves_the_eta_and_holds_the_change`, `nothing_is_proposed_or_applied_while_the_vault_winds_down` | +| The vault's own changes wait on its clock: a raised notice blocks a shorter change, a pause holds one, a wind-down refuses them, and a wind-down waits at least seven days | [`contracts/async-vault/src/test/governance.rs`](../contracts/async-vault/src/test/governance.rs), [`contracts/async-vault/src/test/wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs) | `a_change_shorter_than_a_raised_notice_never_lands`, `a_pending_role_change_lands_after_a_pause`, `nothing_timelocked_moves_during_a_wind_down_a_manager_revoke_included`, `an_unset_delay_still_waits_the_minimum` | | Timing rules hold for any clock values (property tests) | [`contracts/async-vault/src/test/timing.rs`](../contracts/async-vault/src/test/timing.rs) | `standard_priceable_at_never_precedes_the_close`, `a_notice_that_would_overflow_saturates` | ## Events @@ -101,11 +109,14 @@ so time rules and TTL can be reached in one test. | A refused call publishes nothing | [`contracts/async-vault/src/test/events.rs`](../contracts/async-vault/src/test/events.rs) | `a_refused_call_publishes_nothing` | | An epoch's rounding remainder is cleared once its last request settles, a settled epoch leaves nothing in the totals whatever the claim order (property test), and a request that can never settle (a refused redemption or a refused mint) keeps its epoch open | [`contracts/async-vault/src/test/settlement.rs`](../contracts/async-vault/src/test/settlement.rs) | `the_last_redemption_claim_clears_the_cash_remainder_and_says_so_once`, `an_epoch_with_no_requests_settles_when_it_is_priced`, `a_settled_epoch_leaves_no_remainder_in_the_totals`, `a_request_that_cannot_settle_keeps_its_epoch_open`, `a_refused_mint_keeps_a_priced_deposit_epoch_open` | | The oracle publishes the valuation it stored | [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | `attesting_publishes_the_stored_valuation` | +| The vault publishes the end of its setup, and an applied change publishes its own event before `ChangeApplied` | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs), [`contracts/async-vault/src/test/governance.rs`](../contracts/async-vault/src/test/governance.rs) | `finishing_setup_publishes_it`, `applying_a_custodian_change_publishes_both_events` | ## Calls between contracts The vault tests deploy the real share token, identity verifier, compliance -contract and oracle, plus a Stellar Asset Contract for the deposit asset. +contract and oracle, plus a Stellar Asset Contract for the deposit asset. The +fixture wires them as the deploy does and finishes the vault's setup; +`setup_unfinished()` and `miswired(…)` leave the setup open. | Behaviour | Tests | Key test names | | --- | --- | --- | @@ -113,6 +124,7 @@ contract and oracle, plus a Stellar Asset Contract for the deposit asset. | The vault mints and burns shares through its role on the share token | [`contracts/async-vault/src/test/constructor.rs`](../contracts/async-vault/src/test/constructor.rs), [`contracts/async-vault/src/test/redeem.rs`](../contracts/async-vault/src/test/redeem.rs) | `the_vault_holds_the_manager_role_on_the_share_token`, `claim_redeem_pays_assets_and_burns_the_shares` | | The vault checks the identity verifier at request; the share token checks it on mint and transfer | [`contracts/async-vault/src/test/compliance.rs`](../contracts/async-vault/src/test/compliance.rs), [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs) | `an_investor_who_was_never_allowlisted_is_refused_before_any_cash_moves`, `a_frozen_or_delisted_holder_cannot_transfer_shares`, `manager_mints_to_allowlisted_account` | | The vault reads the oracle and refuses a stale or paused feed | [`contracts/async-vault/src/test/oracle_pricing.rs`](../contracts/async-vault/src/test/oracle_pricing.rs) | `the_epoch_is_priced_at_the_attestation`, `fulfill_is_rejected_once_the_feed_goes_stale`, `a_paused_feed_is_refused_by_the_vault_not_the_oracle` | +| The vault finishes setup only once the token and the oracle report it with their own setup closed, it is the token's only `manager`, it passes the identity check and the token is bound to compliance; before that it takes no deposit | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs) | `every_wiring_step_is_checked_before_setup_finishes`, `a_deposit_before_setup_finishes_is_refused`, `setup_cannot_finish_while_a_hand_over_is_pending` | | Upgrading replaces the vault's code and migrates once | [`contracts/async-vault/src/test/upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs) (needs `upgrade_wasm`) | `applying_a_wasm_change_replaces_the_code`, `a_migration_refuses_a_second_run` | | The share token upgrades through the vault's timelock under live balances, an open deposit and an open redemption, and migrates once; called directly, `upgrade` does not exist | [`contracts/async-vault/src/test/upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs) (needs `upgrade_wasm`) | `the_token_upgrades_under_live_balances_and_open_requests`, `the_token_exposes_no_immediate_upgrade` | | The oracle upgrades through the vault's timelock under a live valuation, keeps its config, roles and vault, and migrates once; called directly, `upgrade` does not exist | [`contracts/nav-oracle/src/test_governance.rs`](../contracts/nav-oracle/src/test_governance.rs) (needs `upgrade_wasm`) | `the_oracle_upgrades_under_a_live_valuation`, `the_oracle_exposes_no_immediate_upgrade` | diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 2a379128..25acb850 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -84,7 +84,9 @@ The contracts enforce a set of mathematical and state-machine invariants across - **Definition**: Upgrades and wind-downs cannot execute without advance notice, and delays cannot be set to invalid boundaries. - **Testable Property**: - Upgrade delay $\in [7 \text{ days}, 90 \text{ days}]$ and $\text{delay} \ge \text{notice}$, checked when a delay change is proposed and again when it applies. - - Wind-down delay $\le 90 \text{ days}$. + - Wind-down delay $\in [7 \text{ days}, 90 \text{ days}]$ (`WindDownDelayTooShort`, `WindDownDelayTooLong`); 7 days when none was set. + - The vault takes no deposit until its one-time setup closes (`SetupOpen`). `finish_setup` refuses with `SetupIncomplete` until the share token and the oracle report this vault with their own setup closed, the vault is the token's only `manager`, it passes the token's identity check, and the token is bound to its compliance contract. It refuses with `AdminTransferPending` while an admin hand-over offered during setup is pending. Setup never reopens (`SetupClosed`). + - After setup, the vault's custodian, role grants, revoking `manager`, admin hand-overs and lowering the notice or the wind-down delay go through the queue; a direct call is refused with `UseTimelock`. Raising the notice or the wind-down delay, the deposit cap, `pause`/`unpause` and revoking any other role stay instant. A `Notice` or `WindDownDelay` change is checked against its bounds when proposed and again when it applies. - Every change waits in the queue (`propose_change` → `apply_change`). Applying before its eta reverts with `ChangeNotReady`. - A change whose delay is shorter than the notice standing at apply time reverts with `UpgradeDelayBelowNotice`. - Time spent paused does not count. Applying while paused is refused. @@ -153,10 +155,10 @@ The `async_vault` constructor strictly verifies pairwise separation across autho | Parameter | Method | Authority | Constraints & Defaults | |---|---|---|---| -| **Standing Notice** | `set_notice(secs: u64)` | Governance | Notice wait before pricing. Default: `0`. Must satisfy $\text{secs} \le \text{upgrade\_delay}$ and $\text{secs} \le 30 \text{ days}$ (`MAX_NOTICE_SECS`). | -| **Custodian Address** | `set_custodian(custodian: Address)` | Governance | Recipient address for off-chain capital deployment. Required before `deploy_to_custodian`. | -| **Deposit Cap** | `set_deposit_cap(cap: Option)` | Governance | Maximum capacity for deposited capital. `None` = unbounded. Refuses negative cap. | -| **Wind-Down Delay** | `set_wind_down_delay(secs: u64)` | Governance | Minimum notice before wind-down activation. Default: `0`. Capped at 90 days (`MAX_WIND_DOWN_DELAY`). | +| **Standing Notice** | `set_notice(secs: u64)`; after setup, lowering goes through `propose_change(Notice(secs))` → `apply_change(id)` | Governance | Notice wait before pricing. Default: `0`. Must satisfy $\text{secs} \le \text{upgrade\_delay}$ and $\text{secs} \le 30 \text{ days}$ (`MAX_NOTICE_SECS`), checked again when a `Notice` change applies. Raising is instant. | +| **Custodian Address** | `set_custodian(custodian: Address)` during setup; afterwards `propose_change(Custodian(address))` → `apply_change(id)` | Governance | Recipient address for off-chain capital deployment. Required before `deploy_to_custodian`. The deploy sets it before setup closes. | +| **Deposit Cap** | `set_deposit_cap(cap: Option)` | Governance | Maximum capacity for deposited capital. `None` = unbounded. Refuses negative cap. Instant both ways, also after setup. | +| **Wind-Down Delay** | `set_wind_down_delay(secs: u64)`; after setup, lowering goes through `propose_change(WindDownDelay(secs))` → `apply_change(id)` | Governance | Minimum notice before wind-down activation. Default and minimum 7 days (`MIN_WIND_DOWN_DELAY`), maximum 90 days (`MAX_WIND_DOWN_DELAY`). Raising is instant. | | **Upgrade Delay** | `propose_change(UpgradeDelay(secs))` → `apply_change(id)` | Governance | Timelock delay for wasm upgrades. Must be $\ge 7 \text{ days}$, $\le 90 \text{ days}$, and $\ge \text{notice}$. | | **Oracle Configuration** | `set_config(config: OracleConfig)`; after setup, `propose_change(Config(config))` → `apply_change(id)` | Governance | Updates oracle parameters within the constructor's bounds. `set_config` is instant during the oracle's setup. After it, `set_config` is instant only for a shorter `freshness_duration`: later reports go stale sooner, and no value moves. Every other change, tighter limits included, goes through `propose_change(Config(config))` → `apply_change(id)`, and the bounds are checked again when it applies. A tighter fall cap or a longer cooldown can hold the price above a real loss, and a narrower band can stop pricing. Applying a `Config` change sets every field, freshness included; to keep a cut made after the proposal, cancel the queued change and propose again. | @@ -170,7 +172,7 @@ Strata distributes control across five native Stellar multisig authorities and p | Authority | Quorum / Profile | Authorized Capabilities | Anti-Capabilities (What It Cannot Do) | |---|---|---|---| -| **Governance** | High-quorum deliberative multisig | Set parameters (`notice`, `custodian`, `deposit_cap`, `wind_down_delay`), propose/apply upgrades, propose/cancel wind-down, unpause the vault, pause/unpause the share token, transfer admin. | **Cannot pause the vault directly**; cannot move funds; cannot initiate custodian transfers; cannot attest prices; cannot bypass timelocks. | +| **Governance** | High-quorum deliberative multisig | Set parameters (`notice`, `custodian`, `deposit_cap`, `wind_down_delay`), propose/apply upgrades, propose/cancel wind-down, unpause the vault, pause/unpause the share token, transfer admin, finish each contract's one-time setup. After setup, these wait for the timelock: role grants, revoking the manager, admin hand-over, the custodian, lowering the notice or the wind-down delay, upgrades, the token's compliance and verifier swaps, and oracle config changes other than a shorter freshness window. Revoking the treasury or the guardian stays instant, because taking a power away moves no value. | **Cannot pause the vault directly**; cannot move funds; cannot initiate custodian transfers; cannot attest prices; cannot bypass timelocks. | | **Guardian** | Low-threshold fast-reaction multisig | `pause` (vault); `pause_nav` (oracle emergency price freeze). | **Cannot unpause**; cannot move funds; cannot block redemption requests; cannot block payable claims or cancellations. | | **Treasury** | Operational capital multisig | `deploy_to_custodian` (moves free reserve); `fund` (returns assets from custodian). | **Cannot deploy while anything is uncovered**; cannot touch escrowed subscription assets; cannot alter parameters or pause. | | **Attester** | High-security attestation multisig | `attest` (submits NAV report with proof reference). | **Cannot price outside deviation band**; cannot violate cooldown; cannot operate on the vault directly. | @@ -184,7 +186,7 @@ Strata distributes control across five native Stellar multisig authorities and p | Contract | Entrypoint | Calling Authority | Access Control Guard | Pausable? | |---|---|---|---|---| -| **`AsyncVault`** | `request_deposit` | Investor (Caller) | `from.require_auth()` | Yes (halted by pause) | +| **`AsyncVault`** | `request_deposit` | Investor (Caller) | `from.require_auth()`; refused until setup finishes (`SetupOpen`) | Yes (halted by pause) | | **`AsyncVault`** | `cancel_deposit` | Investor (Caller) | `from.require_auth()` | No (open during pause) | | **`AsyncVault`** | `claim_deposit` | Investor (Caller) | `caller.require_auth()` | No (open during pause) | | **`AsyncVault`** | `request_redeem` | Investor (Caller) | `from.require_auth()` | No (always open) | @@ -194,10 +196,12 @@ Strata distributes control across five native Stellar multisig authorities and p | **`AsyncVault`** | `fulfill_epoch` | Permissionless | None | Yes (halted by pause) | | **`AsyncVault`** | `pause` | Guardian | `#[only_role(caller, "guardian")]` | N/A | | **`AsyncVault`** | `unpause` | Governance | `#[only_admin]` | N/A | -| **`AsyncVault`** | `set_notice` | Governance | `#[only_admin]` | No | -| **`AsyncVault`** | `set_custodian` | Governance | `#[only_admin]` | No | -| **`AsyncVault`** | `set_deposit_cap` | Governance | `#[only_admin]` | No | -| **`AsyncVault`** | `set_wind_down_delay` | Governance | `#[only_admin]` | No | +| **`AsyncVault`** | `set_notice` | Governance | `#[only_admin]`; raising is instant, lowering is instant during setup, then `propose_change` | No | +| **`AsyncVault`** | `set_custodian` | Governance | `#[only_admin]`; instant during setup, then `propose_change` | No | +| **`AsyncVault`** | `set_deposit_cap` | Governance | `#[only_admin]`; instant both ways | No | +| **`AsyncVault`** | `set_wind_down_delay` | Governance | `#[only_admin]`; raising is instant, lowering is instant during setup, then `propose_change`; never below 7 days | No | +| **`AsyncVault`** | `finish_setup` | Governance | `#[only_admin]`; needs the wiring complete and no pending admin hand-over; once | No | +| **`AsyncVault`** | `setup_open` | Permissionless | None (view) | No | | **`AsyncVault`** | `propose_wind_down` | Governance | `#[only_admin]` | No | | **`AsyncVault`** | `cancel_wind_down_proposal` | Governance | `#[only_admin]` | No | | **`AsyncVault`** | `activate_wind_down` | Permissionless | None (requires timelock elapsed) | No | @@ -205,11 +209,13 @@ Strata distributes control across five native Stellar multisig authorities and p | **`AsyncVault`** | `claim_wind_down` | Shareholder (Caller) | `caller.require_auth()` | No | | **`AsyncVault`** | `deploy_to_custodian` | Treasury | `#[only_role(caller, "treasury")]` | No (open during pause) | | **`AsyncVault`** | `fund` | Treasury | None (caller transfers funds) | No | -| **`AsyncVault`** | `propose_change` | Governance | `#[only_admin]` | No | +| **`AsyncVault`** | `propose_change` | Governance | `#[only_admin]`; after setup | No | | **`AsyncVault`** | `cancel_change` | Governance | `#[only_admin]` | No | | **`AsyncVault`** | `apply_change` | Governance | `#[only_admin]` | Refused if paused or winding down | | **`AsyncVault`** | `pending_changes` | Permissionless | None (view) | No | -| **`AsyncVault`** | `transfer_admin_role` | Governance | `#[only_admin]` | No | +| **`AsyncVault`** | `grant_role`, `transfer_admin_role` | Governance | Instant during setup, then `propose_change` (a zero `live_until_ledger` cancels at once) | No | +| **`AsyncVault`** | `revoke_role` | Governance | `treasury`, `guardian`: instant. `manager`: instant during setup, then `propose_change` | No | +| **`AsyncVault`** | `set_role_admin` | None | Always refused (`UseTimelock`) | N/A | | **`AsyncVault`** | `accept_admin_transfer` | Proposed Admin | `caller.require_auth()` | No | | **`NavOracle`** | `attest` | Attester | `#[only_role(caller, "attester")]` | Refused if the NAV is paused | | **`NavOracle`** | `pause_nav` | Guardian | `#[only_role(caller, "guardian")]` | N/A | @@ -258,8 +264,9 @@ A clean deployment executes in three sequenced phases: - Call `IdentityVerifier::allow(vault_address, true)`, signed by the compliance key, so the vault can return escrowed shares on cancelled redemptions. - Call `Compliance::bind_token(share_token_address)` to link SEP-57 rule verification. - Call `NavOracle::bind_vault(vault_address)`, then `NavOracle::finish_setup()`. The oracle's admin changes are instant until then and wait for the vault's timelock afterwards. + - Call `set_custodian(custodian)` on the vault, then `finish_setup()`. It refuses with `SetupIncomplete` until every wiring step above is in place, and with `AdminTransferPending` while an admin hand-over offered during setup is pending. The vault takes no deposit before it. 3. **Phase 3: Operational Parameterization** - - Governance calls `set_notice(secs)` and `set_custodian(custodian_address)`. + - Governance calls `set_notice(secs)`. - Governance sets `deposit_cap` if initial capital intake is bounded. - Oracle reporter submits the genesis NAV attestation (`attest`). @@ -350,3 +357,10 @@ timelock. The vault implements `ClockSource` (`crates/timelock`), and the share token and the oracle read its `timelock_clock` to time their own changes. Both implement `VaultBinding` (`crates/bindings`): `vault` names the vault each is bound to, and `setup_open` says whether its admin changes are still instant. + +The vault's `finish_setup` reads both through `VaultBinding` and refuses until +each reports this vault with its own setup closed. It also reads the token's +`manager` members (OpenZeppelin `AccessControl`), runs `verify_identity` on its +own address, and reads `linked_tokens` from the token's compliance contract +(OpenZeppelin `TokenBinder`). A replacement for any of these must answer those +calls, or the vault's setup cannot finish. diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index 035e4eb3..c339d71b 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -81,7 +81,7 @@ or legal solution, and not a vault for on-chain RWA tokens. | Epoch sealer | manager (vault role, not an authority) | Seals the open epoch (`close_epoch`), nothing else; a vault role with its own key at deploy, meant to become permissionless (§8.4) | | Compliance | compliance | Maintains the allowlist; token interventions (freeze, forced transfer, recovery) | | Guardian | guardian | Pauses the vault, which stops deposit requests, epoch pricing and upgrade application and freezes the upgrade timelock; can never block redemption requests, payable claims, or custodian deployments | -| Governance | governance | Parameters, roles, custodian rotation, upgrades behind a timelock, wind-down proposal and proposal cancellation | +| Governance | governance | Parameters, roles, custodian rotation, upgrades, wind-down proposal and proposal cancellation; once each contract's one-time setup closes, the changes that can move value or shorten an exit wait for the timelock | | Custodian | Not an authority | Off-chain party holding the real-world structure; a genesis-configured slot rotatable only by governance | The authorities are native Stellar multisig accounts assigned at deploy, and @@ -433,7 +433,8 @@ interface today. - The exposed figures (share price, liquid reserve, committed, uncovered) make reserve coverage legible to investors and integrators. - Closing a vault is a dedicated, announced state. Governance proposes a - wind-down and may cancel the proposal during the delay. Once its delay has run, + wind-down and may cancel the proposal during the delay, which is never shorter + than seven days. Once its delay has run, anyone may activate it, so the operator cannot announce a closure and then stall it. Once activated, wind-down is strictly irreversible: the vault permanently ceases regular intake and distributes capital through pro-rata rounds. While it @@ -495,8 +496,8 @@ visible to the authorities that act in it, and each action only to its own | Cycle | manager, attestation, treasury; anyone prices, funds, activates and finalizes | Continuous | Epoch sealing (manager); attestations; transfers to the custodian (treasury); pricing, funding, wind-down activation and round finalization (permissionless) | | Compliance | compliance; verifier admin (compliance by deploy) | Continuous | Allowlist (verifier admin); freeze/unfreeze of an address or of part of its shares, forced transfer (compliance); recovery is not offered, the kit's verifier names no recovery target | | Emergency | guardian, governance; oracle admin and share-token admin (both governance by deploy) | Rare and urgent | Vault pause (guardian) and unpause (governance); oracle NAV pause (oracle guardian, or oracle admin through `set_nav_paused`), reset and `clear_latest` (oracle admin); share-token pause/unpause (share-token admin) | -| Configuration | governance; oracle admin and share-token admin (both governance by deploy) | Rare and deliberate | Custodian slot, deposit cap, notice and wind-down delay (governance); oracle guardrails (oracle admin); compliance and identity-verifier contracts (share-token admin) | -| Governance | governance; oracle admin and share-token admin for their own roles and handover (both governance by deploy) | Very rare | Roles and admin handover on the vault, oracle and share token; upgrade and its delay, wind-down proposal and cancellation (governance) | +| Configuration | governance; oracle admin and share-token admin (both governance by deploy) | Rare and deliberate | Custodian slot, deposit cap, notice and wind-down delay (governance); oracle guardrails (oracle admin); compliance and identity-verifier contracts (share-token admin). After setup, the vault's custodian and a lower notice or wind-down delay wait for the timelock, as do the oracle and token changes listed in §8.9; raising the notice or the delay and the deposit cap stay instant | +| Governance | governance; oracle admin and share-token admin for their own roles and handover (both governance by deploy) | Very rare | Roles and admin handover on the vault, oracle and share token; upgrade and its delay, wind-down proposal and cancellation (governance). After setup, every grant, `manager` revoke, handover and upgrade waits for the timelock; revoking an operational role (treasury, guardian, attester, compliance) stays instant | ```mermaid flowchart LR @@ -588,7 +589,7 @@ public surface given the vault address. | Boundary | Risk | Mitigation | | ---------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Attestation authority | Wrong or compromised reports misprice requests | Multisig reporter; the deviation cap bounds a single report in both directions, and a downward cap of 100% leaves `min_answer` as the only floor; the cooldown bounds frequency; a sustained sequence of biased reports within the cap remains possible, is bounded in speed, and is the monitoring plan's primary alert, with the guardian pause as the reactive control. Residual risk: value transfer between entry and exit cohorts | -| Governance keys | Malicious upgrade | Timelock on every code change and on the delay itself, bounded by a minimum no shorter than the standing notice and a maximum ceiling (90 days). A pause freezes the timelock clock and pushes the ETA by the duration paused, guaranteeing that investors have an unpaused exit window before any code change applies; applying while paused is refused; nothing is proposed or applied while the vault winds down. The share token's upgrades, grants, `manager` revocations, admin handovers and compliance or verifier swaps wait on the same clock once its setup closes. So do the oracle's upgrades, grants, admin handovers and every guardrail change except a shorter freshness window; its NAV pause, `clear_latest` and revoking its attester or guardian stay instant. | +| Governance keys | Malicious upgrade | Timelock on every code change and on the delay itself, bounded by a minimum no shorter than the standing notice and a maximum ceiling (90 days). A pause freezes the timelock clock and pushes the ETA by the duration paused, guaranteeing that investors have an unpaused exit window before any code change applies; applying while paused is refused; nothing is proposed or applied while the vault winds down. The vault's own custodian, role grants, `manager` revocations, admin handovers and lowering of the notice or the wind-down delay wait on that clock once its one-time setup closes, and it takes no deposit before then; revoking its treasury or guardian stays instant. The share token's upgrades, grants, `manager` revocations, admin handovers and compliance or verifier swaps wait on the same clock once its setup closes. So do the oracle's upgrades, grants, admin handovers and every guardrail change except a shorter freshness window; its NAV pause, `clear_latest` and revoking its attester or guardian stay instant. | | Treasury keys | Reserve drained | Only free reserve is movable, only to the genesis-configured custodian, verified on-chain; outbound transfers are blocked while anything is uncovered, and escrowed subscriptions never leave the vault | | Guardian keys | Griefing via pause | Guardian can only pause the vault, which stops deposit requests, epoch pricing and upgrade application and freezes the upgrade timelock; it cannot halt redemptions, custodian deployments, or payable claims, nor move funds; governance unpauses and rotates the vault role. It holds no role on the share token: the token's transfer pause is admin-gated (governance by deploy), not a guardian lever. No contract keeps governance and guardian apart | | Share-token pause | Cancellations and secondary transfers halted | Admin-gated (governance by deploy; the token has no guardian role); it refuses SEP-41 transfers and escrowed-share returns only, never pricing, claims, mint, burn or forced transfer; only the token admin lifts it (§8.5) | From cbf5c3691defc34d65e5cfbe7c36469ad0070349 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:01:51 -0300 Subject: [PATCH 3/3] fix(vault): address setup review round 1 --- README.md | 5 ++++ contracts/async-vault/src/error.rs | 4 +-- contracts/async-vault/src/governance.rs | 24 +++++++++++---- contracts/async-vault/src/lib.rs | 5 ++-- contracts/async-vault/src/test/mod.rs | 33 +++++++++++++++++++-- contracts/async-vault/src/test/setup.rs | 11 ++++--- contracts/async-vault/src/test/wind_down.rs | 31 +++++++++++-------- docs/AUTHORIZATION_MATRIX.md | 4 +-- docs/TESTING.md | 2 +- docs/kit-spec.md | 16 +++++----- docs/strata-product-and-architecture.md | 2 +- environments.toml | 8 ++--- 12 files changed, 100 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index e648d72e..a03b7d40 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,11 @@ npm run test:happy-path -w scripts/harness npm run test:guards -w scripts/harness ``` +Deploy afresh before each run of the suites. The guards leave the vault's +notice at 10 seconds, and lowering it back waits for the timelock, so a second +run against the same deployment can fail wherever it prices an epoch right +after closing it. + ### Keeping a deployment alive Every contract extends its own storage when it is called, but a deployment diff --git a/contracts/async-vault/src/error.rs b/contracts/async-vault/src/error.rs index cb2c1e23..c9d05a05 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -124,8 +124,8 @@ pub enum VaultError { /// Setup has finished, and it never reopens. SetupClosed = 6066, /// The token, the oracle, the manager role, the allowlist or the - /// compliance binding is not wired to this vault yet, so setup cannot - /// finish. + /// compliance binding is not wired to this vault yet, or the token already + /// has shares outstanding, so setup cannot finish. SetupIncomplete = 6067, /// The holder fails the share token's identity check, so the vault takes no /// deposit request from them and returns no escrowed shares to them. diff --git a/contracts/async-vault/src/governance.rs b/contracts/async-vault/src/governance.rs index a4c5f196..3fd08499 100644 --- a/contracts/async-vault/src/governance.rs +++ b/contracts/async-vault/src/governance.rs @@ -1,5 +1,7 @@ use bindings::VaultBindingClient; -use soroban_sdk::{contracttype, panic_with_error, Address, BytesN, Env, Symbol, Vec}; +use soroban_sdk::{ + contracttype, panic_with_error, symbol_short, Address, BytesN, Env, Symbol, Vec, +}; use stellar_access::{ access_control::{self, AccessControlClient, AccessControlStorageKey}, role_transfer, @@ -8,9 +10,12 @@ use stellar_contract_utils::{ pausable::{self, PausableError}, upgradeable, }; -use stellar_tokens::rwa::{ - identity_verification::IdentityVerifierClient, utils::token_binder::TokenBinderClient, - RWATokenClient, +use stellar_tokens::{ + fungible::FungibleTokenClient, + rwa::{ + identity_verification::IdentityVerifierClient, utils::token_binder::TokenBinderClient, + RWATokenClient, + }, }; use timelock::{queue, setup, Clock, Refusal}; @@ -50,11 +55,16 @@ pub struct VaultPendingChange { pub eta: u64, } +/// The share token's role that mints and burns. The vault must hold it alone. +const TOKEN_MANAGER_ROLE: Symbol = symbol_short!("manager"); + /// What `finish_setup` needs to see before deposits open. pub(crate) struct Wiring { pub token_bound: bool, pub oracle_bound: bool, pub sole_token_manager: bool, + /// Shares minted before setup would redeem against depositors' money. + pub no_shares_outstanding: bool, pub allowlisted: bool, pub compliance_bound: bool, } @@ -64,6 +74,7 @@ impl Wiring { self.token_bound && self.oracle_bound && self.sole_token_manager + && self.no_shares_outstanding && self.allowlisted && self.compliance_bound } @@ -137,8 +148,9 @@ pub(crate) fn wiring(e: &Env) -> Wiring { Wiring { token_bound: bound_here(e, &token), oracle_bound: bound_here(e, &oracle), - sole_token_manager: roles.get_role_member_count(&MANAGER_ROLE) == 1 - && roles.get_role_member(&MANAGER_ROLE, &0) == me, + sole_token_manager: roles.get_role_member_count(&TOKEN_MANAGER_ROLE) == 1 + && roles.get_role_member(&TOKEN_MANAGER_ROLE, &0) == me, + no_shares_outstanding: FungibleTokenClient::new(e, &token).total_supply() == 0, allowlisted: matches!( IdentityVerifierClient::new(e, &rwa.identity_verifier()).try_verify_identity(&me), Ok(Ok(())) diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index d8f29593..b5dfb9a9 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -211,6 +211,7 @@ 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); } @@ -285,8 +286,8 @@ impl AsyncVault { /// Ends the window in which admin changes are instant, once the token, /// the oracle, the manager role, the allowlist and the compliance binding - /// are wired to this vault. Deposits open from here on, and it never - /// reopens. + /// are wired to this vault and the token has no shares outstanding. + /// Deposits open from here on, and it never reopens. #[only_admin] pub fn finish_setup(e: &Env, caller: Address) { caller.require_auth(); diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index b69d48d8..51b42d12 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -78,6 +78,21 @@ impl Fixture<'_> { }); } + /// Signs one call to the vault as `who` and nothing else, so the call's own + /// role check is the only thing that can refuse it. + fn sign_only_as(&self, who: &Address, fn_name: &str, args: soroban_sdk::Vec) { + use soroban_sdk::testutils::{MockAuth, MockAuthInvoke}; + self.e.mock_auths(&[MockAuth { + address: who, + invoke: &MockAuthInvoke { + contract: &self.vault.address, + fn_name, + args, + sub_invokes: &[], + }, + }]); + } + fn investor(&self, funded: i128) -> Address { let who = self.unverified_investor(funded); self.identity.allow(&who, &true, &self.compliance); @@ -214,7 +229,9 @@ enum Miswire { OracleBoundElsewhere, OracleSetupOpen, NoManagerRole, + OtherManager, SecondManager, + PreMinted, NotAllowlisted, ComplianceUnbound, } @@ -318,12 +335,24 @@ fn build<'a>( }, ), ); - if miswire != Miswire::NoManagerRole { - share.grant_role(&contract_id, &symbol_short!("manager"), &admin); + match miswire { + Miswire::NoManagerRole => {} + Miswire::OtherManager => { + share.grant_role(&Address::generate(&e), &symbol_short!("manager"), &admin) + } + _ => share.grant_role(&contract_id, &symbol_short!("manager"), &admin), } if miswire == Miswire::SecondManager { share.grant_role(&Address::generate(&e), &symbol_short!("manager"), &admin); } + if miswire == Miswire::PreMinted { + // The token's admin mints during the token's own setup, then steps back. + let holder = Address::generate(&e); + identity.allow(&holder, &true, &compliance_authority); + share.grant_role(&admin, &symbol_short!("manager"), &admin); + share.mint(&holder, &100, &admin); + share.revoke_role(&admin, &symbol_short!("manager"), &admin); + } // The vault holds escrowed shares, so returning them on cancellation goes // through the token's checked transfer. That verifies both sides, so the // vault has to be a recognised holder itself. diff --git a/contracts/async-vault/src/test/setup.rs b/contracts/async-vault/src/test/setup.rs index 11500de5..f2d9a719 100644 --- a/contracts/async-vault/src/test/setup.rs +++ b/contracts/async-vault/src/test/setup.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{testutils::Events as _, Event as _}; +use soroban_sdk::{testutils::Events as _, Event as _, IntoVal}; use stellar_access::role_transfer::RoleTransferError; use super::*; @@ -56,7 +56,9 @@ fn every_wiring_step_is_checked_before_setup_finishes() { Miswire::OracleBoundElsewhere, Miswire::OracleSetupOpen, Miswire::NoManagerRole, + Miswire::OtherManager, Miswire::SecondManager, + Miswire::PreMinted, Miswire::NotAllowlisted, Miswire::ComplianceUnbound, ] { @@ -111,9 +113,10 @@ fn only_governance_finishes_setup() { let f = setup_unfinished(); let stranger = Address::generate(&f.e); - f.e.set_auths(&[]); - assert!(f.vault.try_finish_setup(&stranger).is_err()); - assert!(f.vault.try_finish_setup(&f.manager).is_err()); + for who in [&stranger, &f.manager] { + f.sign_only_as(who, "finish_setup", (who.clone(),).into_val(&f.e)); + assert!(f.vault.try_finish_setup(who).is_err()); + } assert!(f.vault.setup_open()); } diff --git a/contracts/async-vault/src/test/wind_down.rs b/contracts/async-vault/src/test/wind_down.rs index 6ea7137e..87b5499d 100644 --- a/contracts/async-vault/src/test/wind_down.rs +++ b/contracts/async-vault/src/test/wind_down.rs @@ -1,4 +1,5 @@ use proptest::prelude::*; +use soroban_sdk::IntoVal; use super::*; @@ -27,12 +28,16 @@ fn the_delay_cannot_exceed_the_maximum() { fn only_governance_sets_the_delay() { let f = setup(); let stranger = Address::generate(&f.e); - - f.e.set_auths(&[]); - assert!(f.vault.try_set_wind_down_delay(&60, &stranger).is_err()); - assert!(f.vault.try_set_wind_down_delay(&60, &f.manager).is_err()); - assert!(f.vault.try_set_wind_down_delay(&60, &f.treasury).is_err()); - assert!(f.vault.try_set_wind_down_delay(&60, &f.guardian).is_err()); + let longer = 2 * MIN_WIND_DOWN_DELAY; + + for who in [&stranger, &f.manager, &f.treasury, &f.guardian] { + f.sign_only_as( + who, + "set_wind_down_delay", + (longer, who.clone()).into_val(&f.e), + ); + assert!(f.vault.try_set_wind_down_delay(&longer, who).is_err()); + } assert_eq!(f.vault.wind_down_delay(), MIN_WIND_DOWN_DELAY); } @@ -163,13 +168,13 @@ fn an_unset_delay_still_waits_the_minimum() { #[test] fn a_delay_below_the_minimum_is_refused() { - let f = setup_unfinished(); - - refused( - f.vault - .try_set_wind_down_delay(&(MIN_WIND_DOWN_DELAY - 1), &f.admin), - VaultError::WindDownDelayTooShort, - ); + for f in [setup_unfinished(), setup()] { + refused( + f.vault + .try_set_wind_down_delay(&(MIN_WIND_DOWN_DELAY - 1), &f.admin), + VaultError::WindDownDelayTooShort, + ); + } } /// Proposes, waits and activates. Returns nothing; the vault is active after. diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index fa70b0ff..93d2e679 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -38,7 +38,7 @@ In `scripts/harness/deploy.ts` and automated deployment scripts, the separation - After granting, the script checks `token.has_role({ account: vault, role: "manager" })` and stops if the vault does not hold the role. That no other account holds it is checked by the vault's `finish_setup` below. - It then calls `token.bind_vault({ vault, caller: governance })` and `token.finish_setup({ caller: governance })`, and stops if `token.setup_open()` still reads true. From then on the token's admin changes wait for the vault's timelock (§5.2). - It calls `oracle.bind_vault({ vault, caller: governance })` and `oracle.finish_setup({ caller: governance })`, and stops if `oracle.setup_open()` still reads true. From then on the oracle's admin changes wait for the vault's timelock (§5.3). -- Last, it calls `vault.set_custodian({ custodian, caller: governance })` and `vault.finish_setup({ caller: governance })`, and stops if `vault.setup_open()` still reads true. `finish_setup` refuses with `SetupIncomplete` unless the token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the vault passes the identity check and the token is bound to its compliance contract. From then on the vault takes deposits and its admin changes wait for its timelock (§5.1). +- Last, it calls `vault.set_custodian({ custodian, caller: governance })` and `vault.finish_setup({ caller: governance })`, and stops if `vault.setup_open()` still reads true. `finish_setup` refuses with `SetupIncomplete` unless the token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the token has no shares outstanding, the vault passes the identity check and the token is bound to its compliance contract. From then on the vault takes deposits and its admin changes wait for its timelock (§5.1). --- @@ -90,7 +90,7 @@ Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs. | `set_notice` | `#[only_admin]`, `caller.require_auth()` | `secs <= MAX_NOTICE_SECS`, `secs <= upgrade_delay`. Raising is instant; lowering is `UseTimelock` after setup | `test::governance::raising_the_notice_is_instant_and_lowering_waits`, `test::notice::setting_the_notice_needs_governance_authorisation` | | `set_wind_down_delay` | `#[only_admin]`, `caller.require_auth()` | `MIN_WIND_DOWN_DELAY <= secs <= MAX_WIND_DOWN_DELAY`. Raising is instant; lowering is `UseTimelock` after setup | `test::governance::raising_the_wind_down_delay_is_instant_and_lowering_waits`, `test::governance::a_wind_down_delay_below_the_minimum_cannot_be_proposed`, `test::wind_down::only_governance_sets_the_delay` | | `set_deposit_cap` | `#[only_admin]`, `caller.require_auth()` | Not negative. Instant both ways, also after setup | `test::governance::the_deposit_cap_stays_instant_both_ways` | -| `finish_setup` | `#[only_admin]`, `caller.require_auth()` | Once. The token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, passes `verify_identity`, and the token is in its compliance contract's `linked_tokens`; no admin hand-over offered during setup is pending | `test::setup::every_wiring_step_is_checked_before_setup_finishes`, `test::setup::setup_cannot_finish_while_a_hand_over_is_pending`, `test::setup::setup_never_finishes_twice`, `test::setup::only_governance_finishes_setup` | +| `finish_setup` | `#[only_admin]`, `caller.require_auth()` | Once. The token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the token's `total_supply` is zero, the vault passes `verify_identity`, and the token is in its compliance contract's `linked_tokens`; no admin hand-over offered during setup is pending | `test::setup::every_wiring_step_is_checked_before_setup_finishes`, `test::setup::setup_cannot_finish_while_a_hand_over_is_pending`, `test::setup::setup_never_finishes_twice`, `test::setup::only_governance_finishes_setup` | | `grant_role` / `transfer_admin_role` | Admin | Instant during setup, `UseTimelock` after. A zero `live_until_ledger` cancels instantly | `test::governance::grants_and_manager_revokes_wait_for_the_timelock`, `test::governance::an_admin_hand_over_waits_but_cancelling_one_does_not` | | `revoke_role` | Admin | `treasury`, `guardian`: instant. `manager`: instant during setup, `UseTimelock` after | `test::governance::grants_and_manager_revokes_wait_for_the_timelock` (instant case: `test::governance::revoking_treasury_or_guardian_stays_instant`; caller check: `test::governance::only_the_admin_revokes_a_role`) | | `set_role_admin` | — | Always refused (`UseTimelock`) | `test::governance::no_role_ever_gets_a_role_admin` | diff --git a/docs/TESTING.md b/docs/TESTING.md index 81191b58..af002604 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -124,7 +124,7 @@ fixture wires them as the deploy does and finishes the vault's setup; | The vault mints and burns shares through its role on the share token | [`contracts/async-vault/src/test/constructor.rs`](../contracts/async-vault/src/test/constructor.rs), [`contracts/async-vault/src/test/redeem.rs`](../contracts/async-vault/src/test/redeem.rs) | `the_vault_holds_the_manager_role_on_the_share_token`, `claim_redeem_pays_assets_and_burns_the_shares` | | The vault checks the identity verifier at request; the share token checks it on mint and transfer | [`contracts/async-vault/src/test/compliance.rs`](../contracts/async-vault/src/test/compliance.rs), [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs) | `an_investor_who_was_never_allowlisted_is_refused_before_any_cash_moves`, `a_frozen_or_delisted_holder_cannot_transfer_shares`, `manager_mints_to_allowlisted_account` | | The vault reads the oracle and refuses a stale or paused feed | [`contracts/async-vault/src/test/oracle_pricing.rs`](../contracts/async-vault/src/test/oracle_pricing.rs) | `the_epoch_is_priced_at_the_attestation`, `fulfill_is_rejected_once_the_feed_goes_stale`, `a_paused_feed_is_refused_by_the_vault_not_the_oracle` | -| The vault finishes setup only once the token and the oracle report it with their own setup closed, it is the token's only `manager`, it passes the identity check and the token is bound to compliance; before that it takes no deposit | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs) | `every_wiring_step_is_checked_before_setup_finishes`, `a_deposit_before_setup_finishes_is_refused`, `setup_cannot_finish_while_a_hand_over_is_pending` | +| The vault finishes setup only once the token and the oracle report it with their own setup closed, it is the token's only `manager`, the token has no shares outstanding, it passes the identity check and the token is bound to compliance; before that it takes no deposit | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs) | `every_wiring_step_is_checked_before_setup_finishes`, `a_deposit_before_setup_finishes_is_refused`, `setup_cannot_finish_while_a_hand_over_is_pending` | | Upgrading replaces the vault's code and migrates once | [`contracts/async-vault/src/test/upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs) (needs `upgrade_wasm`) | `applying_a_wasm_change_replaces_the_code`, `a_migration_refuses_a_second_run` | | The share token upgrades through the vault's timelock under live balances, an open deposit and an open redemption, and migrates once; called directly, `upgrade` does not exist | [`contracts/async-vault/src/test/upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs) (needs `upgrade_wasm`) | `the_token_upgrades_under_live_balances_and_open_requests`, `the_token_exposes_no_immediate_upgrade` | | The oracle upgrades through the vault's timelock under a live valuation, keeps its config, roles and vault, and migrates once; called directly, `upgrade` does not exist | [`contracts/nav-oracle/src/test_governance.rs`](../contracts/nav-oracle/src/test_governance.rs) (needs `upgrade_wasm`) | `the_oracle_upgrades_under_a_live_valuation`, `the_oracle_exposes_no_immediate_upgrade` | diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 25acb850..91fe5d71 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -85,7 +85,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - **Testable Property**: - Upgrade delay $\in [7 \text{ days}, 90 \text{ days}]$ and $\text{delay} \ge \text{notice}$, checked when a delay change is proposed and again when it applies. - Wind-down delay $\in [7 \text{ days}, 90 \text{ days}]$ (`WindDownDelayTooShort`, `WindDownDelayTooLong`); 7 days when none was set. - - The vault takes no deposit until its one-time setup closes (`SetupOpen`). `finish_setup` refuses with `SetupIncomplete` until the share token and the oracle report this vault with their own setup closed, the vault is the token's only `manager`, it passes the token's identity check, and the token is bound to its compliance contract. It refuses with `AdminTransferPending` while an admin hand-over offered during setup is pending. Setup never reopens (`SetupClosed`). + - The vault takes no deposit until its one-time setup closes (`SetupOpen`). `finish_setup` refuses with `SetupIncomplete` until the share token and the oracle report this vault with their own setup closed, the vault is the token's only `manager`, the token has no shares outstanding, the vault passes the token's identity check, and the token is bound to its compliance contract. Shares minted before setup closes would redeem against depositors' money. It refuses with `AdminTransferPending` while an admin hand-over offered during setup is pending. Setup never reopens (`SetupClosed`). - After setup, the vault's custodian, role grants, revoking `manager`, admin hand-overs and lowering the notice or the wind-down delay go through the queue; a direct call is refused with `UseTimelock`. Raising the notice or the wind-down delay, the deposit cap, `pause`/`unpause` and revoking any other role stay instant. A `Notice` or `WindDownDelay` change is checked against its bounds when proposed and again when it applies. - Every change waits in the queue (`propose_change` → `apply_change`). Applying before its eta reverts with `ChangeNotReady`. - A change whose delay is shorter than the notice standing at apply time reverts with `UpgradeDelayBelowNotice`. @@ -264,10 +264,9 @@ A clean deployment executes in three sequenced phases: - Call `IdentityVerifier::allow(vault_address, true)`, signed by the compliance key, so the vault can return escrowed shares on cancelled redemptions. - Call `Compliance::bind_token(share_token_address)` to link SEP-57 rule verification. - Call `NavOracle::bind_vault(vault_address)`, then `NavOracle::finish_setup()`. The oracle's admin changes are instant until then and wait for the vault's timelock afterwards. - - Call `set_custodian(custodian)` on the vault, then `finish_setup()`. It refuses with `SetupIncomplete` until every wiring step above is in place, and with `AdminTransferPending` while an admin hand-over offered during setup is pending. The vault takes no deposit before it. + - On the vault, call `set_custodian(custodian)`, `set_notice(secs)` and, if initial capital intake is bounded, `set_deposit_cap(cap)`. All three are instant during setup, so deposits never open without the cap. + - Then call `finish_setup()` on the vault. It refuses with `SetupIncomplete` until every wiring step above is in place and the token has no shares outstanding, and with `AdminTransferPending` while an admin hand-over offered during setup is pending. The vault takes no deposit before it. 3. **Phase 3: Operational Parameterization** - - Governance calls `set_notice(secs)`. - - Governance sets `deposit_cap` if initial capital intake is bounded. - Oracle reporter submits the genesis NAV attestation (`attest`). --- @@ -360,7 +359,8 @@ bound to, and `setup_open` says whether its admin changes are still instant. The vault's `finish_setup` reads both through `VaultBinding` and refuses until each reports this vault with its own setup closed. It also reads the token's -`manager` members (OpenZeppelin `AccessControl`), runs `verify_identity` on its -own address, and reads `linked_tokens` from the token's compliance contract -(OpenZeppelin `TokenBinder`). A replacement for any of these must answer those -calls, or the vault's setup cannot finish. +`manager` members (OpenZeppelin `AccessControl`) and its `total_supply` +(`FungibleToken`), runs `verify_identity` on its own address, and reads +`linked_tokens` from the token's compliance contract (OpenZeppelin +`TokenBinder`). A replacement for any of these must answer those calls, or the +vault's setup cannot finish. diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index c339d71b..04b6bed7 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -589,7 +589,7 @@ public surface given the vault address. | Boundary | Risk | Mitigation | | ---------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Attestation authority | Wrong or compromised reports misprice requests | Multisig reporter; the deviation cap bounds a single report in both directions, and a downward cap of 100% leaves `min_answer` as the only floor; the cooldown bounds frequency; a sustained sequence of biased reports within the cap remains possible, is bounded in speed, and is the monitoring plan's primary alert, with the guardian pause as the reactive control. Residual risk: value transfer between entry and exit cohorts | -| Governance keys | Malicious upgrade | Timelock on every code change and on the delay itself, bounded by a minimum no shorter than the standing notice and a maximum ceiling (90 days). A pause freezes the timelock clock and pushes the ETA by the duration paused, guaranteeing that investors have an unpaused exit window before any code change applies; applying while paused is refused; nothing is proposed or applied while the vault winds down. The vault's own custodian, role grants, `manager` revocations, admin handovers and lowering of the notice or the wind-down delay wait on that clock once its one-time setup closes, and it takes no deposit before then; revoking its treasury or guardian stays instant. The share token's upgrades, grants, `manager` revocations, admin handovers and compliance or verifier swaps wait on the same clock once its setup closes. So do the oracle's upgrades, grants, admin handovers and every guardrail change except a shorter freshness window; its NAV pause, `clear_latest` and revoking its attester or guardian stay instant. | +| Governance keys | Malicious upgrade | Timelock on every code change and on the delay itself, bounded by a minimum no shorter than the standing notice and a maximum ceiling (90 days). A pause freezes the timelock clock and pushes the ETA by the duration paused, guaranteeing that investors have an unpaused exit window before any code change applies; applying while paused is refused; nothing is proposed or applied while the vault winds down. The vault's own custodian, role grants, `manager` revocations, admin handovers and lowering of the notice or the wind-down delay wait on that clock once its one-time setup closes, and it takes no deposit before then; revoking its treasury or guardian stays instant. Setup closes only once the token and the oracle run on the vault's clock with their own setup closed, the vault is the token's only minter, no shares exist yet (shares minted earlier would redeem against depositors' money), the vault passes the identity check and the token is bound to its compliance contract. The share token's upgrades, grants, `manager` revocations, admin handovers and compliance or verifier swaps wait on the same clock once its setup closes. So do the oracle's upgrades, grants, admin handovers and every guardrail change except a shorter freshness window; its NAV pause, `clear_latest` and revoking its attester or guardian stay instant. | | Treasury keys | Reserve drained | Only free reserve is movable, only to the genesis-configured custodian, verified on-chain; outbound transfers are blocked while anything is uncovered, and escrowed subscriptions never leave the vault | | Guardian keys | Griefing via pause | Guardian can only pause the vault, which stops deposit requests, epoch pricing and upgrade application and freezes the upgrade timelock; it cannot halt redemptions, custodian deployments, or payable claims, nor move funds; governance unpauses and rotates the vault role. It holds no role on the share token: the token's transfer pause is admin-gated (governance by deploy), not a guardian lever. No contract keeps governance and guardian apart | | Share-token pause | Cancellations and secondary transfers halted | Admin-gated (governance by deploy; the token has no guardian role); it refuses SEP-41 transfers and escrowed-share returns only, never pricing, claims, mint, burn or forced transfer; only the token admin lifts it (§8.5) | diff --git a/environments.toml b/environments.toml index 41acc24a..72e244f7 100644 --- a/environments.toml +++ b/environments.toml @@ -55,10 +55,10 @@ constructor_args = '--asset share_token --share_token share_token --oracle nav_o # # Setup cannot finish from this file either. The vault's `finish_setup` checks # the token and oracle bindings, the manager role, the allowlist and the -# compliance binding, which only the harness deploy wires. A deploy made only -# from this file leaves the token's, the oracle's and the vault's setup open, so -# its vault refuses deposits with `SetupOpen`. The harness deploy closes all -# three. +# compliance binding, which only the harness deploy wires, and that the token +# has no shares outstanding. A deploy made only from this file leaves the +# token's, the oracle's and the vault's setup open, so its vault refuses +# deposits with `SetupOpen`. The harness deploy closes all three. ### Staging environment configuration