diff --git a/Cargo.lock b/Cargo.lock index de5da08..8b6319a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1708,6 +1708,7 @@ dependencies = [ name = "share-token" version = "0.1.0" dependencies = [ + "bindings", "compliance", "identity-verifier", "soroban-sdk", @@ -1716,6 +1717,7 @@ dependencies = [ "stellar-macros", "stellar-tokens", "storage", + "timelock", ] [[package]] diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index b807f91..5660486 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -199,6 +199,18 @@ fn setup<'a>() -> Fixture<'a> { /// 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)) +} + +/// 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>( + feed: OracleConfig, + register_token: impl FnOnce(&Env, TokenArgs) -> Address, +) -> Fixture<'a> { let e = Env::default(); e.mock_all_auths(); e.ledger().set_timestamp(10_000); @@ -221,8 +233,8 @@ fn setup_with_feed<'a>(feed: OracleConfig) -> Fixture<'a> { let share = ShareTokenClient::new( &e, - &e.register( - ShareToken, + ®ister_token( + &e, ( String::from_str(&e, "Strata Vault USDC"), String::from_str(&e, "bvUSDC"), diff --git a/contracts/async-vault/src/test/upgrade.rs b/contracts/async-vault/src/test/upgrade.rs index 1ab20f7..9c30f5d 100644 --- a/contracts/async-vault/src/test/upgrade.rs +++ b/contracts/async-vault/src/test/upgrade.rs @@ -411,12 +411,69 @@ fn the_vault_reports_its_clock_to_the_token_and_the_oracle() { #[cfg(feature = "upgrade_wasm")] mod with_wasm { + use share_token::TokenChange; + use super::*; soroban_sdk::contractimport!( file = "../../fixtures/upgrade-target/target/wasm32v1-none/release/upgrade_target.wasm" ); + mod token_v1 { + soroban_sdk::contractimport!(file = "../../target/wasm32v1-none/release/share_token.wasm"); + } + + mod token_probe { + soroban_sdk::contractimport!( + file = "../../target/upgrade-probe/wasm32v1-none/release/share_token.wasm" + ); + } + + #[test] + fn the_token_upgrades_under_live_balances_and_open_requests() { + let f = setup_with(open_feed(), |e, args| e.register(token_v1::WASM, args)); + let holder = f.holder(100); + let other = f.investor(1); + let epoch = f.vault.request_redeem(&holder, &40); + 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 + .propose_change(&TokenChange::Upgrade(probe_hash), &f.admin); + f.advance(MIN_UPGRADE_DELAY); + f.share.apply_change(&id, &f.admin); + + let probe = token_probe::Client::new(&f.e, &f.share.address); + assert!(probe.probe()); + assert_eq!(probe.schema_version(), 1); + probe.migrate(&f.admin); + assert_eq!(probe.schema_version(), 2); + assert_eq!( + probe.try_migrate(&f.admin), + Err(Ok(token_probe::ProbeError::AlreadyMigrated.into())) + ); + + assert_eq!(f.shares(&holder), 60); + assert_eq!(f.shares(&f.vault.address), 40); + assert_eq!( + f.vault.get_redeem_request(&epoch, &holder).unwrap().shares, + 40 + ); + + f.fulfill_epoch_at(f.close_epoch(), wad(2)); + assert_eq!(f.vault.claim_redeem(&holder, &epoch), 80); + assert_eq!(f.vault.claim_deposit(&depositor, &epoch), 250); + assert_eq!(f.shares(&depositor), 250); + assert_eq!(f.shares(&f.vault.address), 0); + + f.share.transfer(&holder, &other, &10); + assert_eq!(f.shares(&other), 10); + } + #[test] fn immediate_upgrade_with_valid_wasm_does_not_replace_code() { let f = setup(); diff --git a/contracts/share-token/Cargo.toml b/contracts/share-token/Cargo.toml index d06350f..f26c96d 100644 --- a/contracts/share-token/Cargo.toml +++ b/contracts/share-token/Cargo.toml @@ -16,8 +16,14 @@ stellar-macros = { workspace = true } stellar-tokens = { workspace = true } stellar-contract-utils = { workspace = true } storage = { path = "../../crates/storage" } +timelock = { path = "../../crates/timelock" } +bindings = { path = "../../crates/bindings" } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } compliance = { path = "../compliance" } identity-verifier = { path = "../identity-verifier" } + +[features] +# Builds a stand-in for a later token version, used only by the upgrade tests. +upgrade_probe = [] diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index 7cd1c85..29394bd 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -1,11 +1,14 @@ use crate::error::ShareTokenError; +use crate::governance::{self, TokenChange, TokenPendingChange}; use crate::roles::COMPLIANCE_ROLE; +use bindings::VaultBinding; use soroban_sdk::{ contract, contractimpl, panic_with_error, Address, Env, MuxedAddress, String, Symbol, Vec, }; use stellar_access::access_control::{self as access_control, AccessControl}; use stellar_contract_utils::pausable::{self as pausable, Pausable}; +use stellar_contract_utils::upgradeable; use stellar_macros::{only_admin, only_any_role, only_role}; use stellar_tokens::{ fungible::{Base, ContractOverrides, FungibleToken}, @@ -36,8 +39,69 @@ impl ShareToken { ); RWA::set_compliance(e, &compliance); RWA::set_identity_verifier(e, &identity_verifier); + upgradeable::set_schema_version(e, 1); storage::bump_instance(e); } + + pub fn schema_version(e: &Env) -> u32 { + upgradeable::get_schema_version(e) + } + + /// Names the vault whose clock this token's timelock reads. Once, and only + /// during setup. + #[only_admin] + pub fn bind_vault(e: &Env, vault: Address, caller: Address) { + caller.require_auth(); + storage::bump_instance(e); + governance::bind_vault(e, &vault); + } + + /// Ends the window in which admin changes are instant. It never reopens. + #[only_admin] + pub fn finish_setup(e: &Env, caller: Address) { + caller.require_auth(); + storage::bump_instance(e); + governance::finish_setup(e); + } + + /// Queues a change behind the vault's timelock and returns its id. + #[only_admin] + pub fn propose_change(e: &Env, change: TokenChange, caller: Address) -> u32 { + caller.require_auth(); + storage::bump_instance(e); + governance::propose(e, change) + } + + #[only_admin] + pub fn cancel_change(e: &Env, id: u32, caller: Address) { + caller.require_auth(); + storage::bump_instance(e); + governance::cancel(e, id); + } + + /// Lands a change whose delay has run. Refused while the vault is paused + /// or winding down. + #[only_admin] + pub fn apply_change(e: &Env, id: u32, caller: Address) { + caller.require_auth(); + storage::bump_instance(e); + governance::apply(e, id); + } + + pub fn pending_changes(e: &Env) -> Vec { + governance::pending(e) + } +} + +#[contractimpl(contracttrait)] +impl VaultBinding for ShareToken { + fn vault(e: &Env) -> Option
{ + timelock::setup::source(e) + } + + fn setup_open(e: &Env) -> bool { + timelock::setup::is_open(e) + } } #[contractimpl(contracttrait)] @@ -86,9 +150,40 @@ impl FungibleToken for ShareToken { #[contractimpl(contracttrait)] impl AccessControl for ShareToken { + /// Instant during setup; after it, propose the grant through the timelock. + fn grant_role(e: &Env, account: Address, role: Symbol, caller: Address) { + governance::require_setup_open(e); + storage::bump_instance(e); + access_control::grant_role(e, &account, &role, &caller); + } + + /// Instant for `compliance`. Revoking `manager` waits like a grant, + /// because the vault needs it to pay claims. + fn revoke_role(e: &Env, account: Address, role: Symbol, caller: Address) { + governance::require_revoke_allowed(e, &role); + storage::bump_instance(e); + 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); + } + storage::bump_instance(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, ShareTokenError::UseTimelock); + } + /// Refused. A token without an admin could never be unpaused, never rotate - /// the manager role and never replace the vault that holds it. - /// `transfer_admin_role` and `accept_admin_transfer` are the way out. + /// the manager role and never replace the vault that holds it. 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, ShareTokenError::AdminRequired); } @@ -140,13 +235,42 @@ impl RWAToken for ShareToken { #[only_admin] fn set_compliance(e: &Env, compliance: Address, operator: Address) { operator.require_auth(); + governance::require_setup_open(e); storage::bump_instance(e); RWA::set_compliance(e, &compliance); } #[only_admin] fn set_identity_verifier(e: &Env, identity_verifier: Address, operator: Address) { operator.require_auth(); + governance::require_setup_open(e); storage::bump_instance(e); RWA::set_identity_verifier(e, &identity_verifier); } } + +/// Stands in for a later token version in the upgrade tests. Never deployed. +#[cfg(feature = "upgrade_probe")] +#[soroban_sdk::contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum ProbeError { + AlreadyMigrated = 7100, +} + +#[cfg(feature = "upgrade_probe")] +#[contractimpl] +impl ShareToken { + #[only_admin] + pub fn migrate(e: &Env, caller: Address) { + caller.require_auth(); + storage::bump_instance(e); + if upgradeable::get_schema_version(e) >= 2 { + panic_with_error!(e, ProbeError::AlreadyMigrated); + } + upgradeable::set_schema_version(e, 2); + } + + pub fn probe(_e: &Env) -> bool { + true + } +} diff --git a/contracts/share-token/src/error.rs b/contracts/share-token/src/error.rs index 1188392..b3b950b 100644 --- a/contracts/share-token/src/error.rs +++ b/contracts/share-token/src/error.rs @@ -7,7 +7,36 @@ use soroban_sdk::contracterror; #[repr(u32)] pub enum ShareTokenError { /// The token must always have an admin: `pause`, `unpause` and role - /// management need it. The admin hands over through `transfer_admin_role` - /// and `accept_admin_transfer` instead. + /// management need it. Hand it over instead: propose and apply an `Admin` + /// change (`transfer_admin_role` during setup), then the nominee calls + /// `accept_admin_transfer`. AdminRequired = 7000, + /// No pending change has this id. + ChangeNotFound = 7001, + /// The change's delay has not elapsed. + ChangeNotReady = 7002, + /// As many changes as the timelock holds already wait. + TooManyPendingChanges = 7003, + /// Changes are instant until setup finishes; there is nothing to propose yet. + SetupOpen = 7004, + /// A direct call cannot make this change. After setup it goes through + /// `propose_change`; setting a role admin is never allowed. + UseTimelock = 7005, + /// Only possible while setup is open, and only once. + SetupClosed = 7006, + /// Setup cannot finish before the token is bound to its vault. + VaultNotBound = 7007, + /// The vault is winding down, so no change may be proposed or applied. + WindDownActive = 7008, + /// The vault is paused, which holds every timelocked change. + VaultPaused = 7009, + /// The change's delay is shorter than the vault's notice, so an investor + /// could not complete an exit before it lands. + DelayBelowNotice = 7010, + /// An upgrade already waits. Cancel it before proposing another. + UpgradeAlreadyPending = 7011, + /// 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 = 7016, } diff --git a/contracts/share-token/src/event.rs b/contracts/share-token/src/event.rs new file mode 100644 index 0000000..524c24a --- /dev/null +++ b/contracts/share-token/src/event.rs @@ -0,0 +1,33 @@ +use soroban_sdk::{contractevent, Address}; + +use crate::governance::TokenChange; + +#[contractevent] +pub struct ChangeProposed { + #[topic] + pub id: u32, + pub change: TokenChange, + pub eta: u64, +} + +#[contractevent] +pub struct ChangeCancelled { + #[topic] + pub id: u32, +} + +#[contractevent] +pub struct ChangeApplied { + #[topic] + pub id: u32, + pub change: TokenChange, +} + +#[contractevent] +pub struct SetupFinished {} + +#[contractevent] +pub struct VaultBound { + #[topic] + pub vault: Address, +} diff --git a/contracts/share-token/src/governance.rs b/contracts/share-token/src/governance.rs new file mode 100644 index 0000000..b22f960 --- /dev/null +++ b/contracts/share-token/src/governance.rs @@ -0,0 +1,178 @@ +use soroban_sdk::{contracttype, panic_with_error, Address, BytesN, Env, Symbol, Vec}; +use stellar_access::{ + access_control::{self, AccessControlStorageKey}, + role_transfer, +}; +use stellar_contract_utils::upgradeable; +use stellar_tokens::rwa::RWA; +use timelock::{queue, setup, Clock, Refusal, TimelockHost}; + +use crate::error::ShareTokenError; +use crate::event::{ChangeApplied, ChangeCancelled, ChangeProposed, SetupFinished, VaultBound}; +use crate::roles::MANAGER_ROLE; +use crate::ShareToken; + +/// 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 vault's timelock before it lands. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TokenChange { + Upgrade(BytesN<32>), + GrantRole(Symbol, Address), + RevokeRole(Symbol, Address), + Admin(Address), + Compliance(Address), + IdentityVerifier(Address), +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TokenPendingChange { + pub id: u32, + pub change: TokenChange, + pub eta: u64, +} + +/// The token keeps no clock of its own: it reads the vault's. +impl TimelockHost for ShareToken { + fn clock(e: &Env) -> Result { + timelock::clock::from_source(e) + } +} + +fn refuse(e: &Env, refusal: Refusal) -> ! { + match refusal { + Refusal::WindingDown => panic_with_error!(e, ShareTokenError::WindDownActive), + Refusal::Paused => panic_with_error!(e, ShareTokenError::VaultPaused), + Refusal::NotElapsed => panic_with_error!(e, ShareTokenError::ChangeNotReady), + Refusal::BelowExitWindow => panic_with_error!(e, ShareTokenError::DelayBelowNotice), + Refusal::NotFound => panic_with_error!(e, ShareTokenError::ChangeNotFound), + Refusal::TooMany => panic_with_error!(e, ShareTokenError::TooManyPendingChanges), + Refusal::SetupOpen => panic_with_error!(e, ShareTokenError::SetupOpen), + Refusal::SetupClosed | Refusal::AlreadyBound => { + panic_with_error!(e, ShareTokenError::SetupClosed) + } + Refusal::Unbound => panic_with_error!(e, ShareTokenError::VaultNotBound), + } +} + +fn vault_clock(e: &Env) -> Clock { + ::clock(e).unwrap_or_else(|r| refuse(e, r)) +} + +/// Direct admin calls are instant only before setup finishes. +pub(crate) fn require_setup_open(e: &Env) { + if !setup::is_open(e) { + panic_with_error!(e, ShareTokenError::UseTimelock); + } +} + +/// Taking `manager` away stops every claim, so after setup it waits like a +/// grant. Taking any other role away moves no value and stays instant. +pub(crate) fn require_revoke_allowed(e: &Env, role: &Symbol) { + if *role == MANAGER_ROLE { + require_setup_open(e); + } +} + +fn upgrade_pending(e: &Env) -> bool { + queue::open_ids(e).iter().any(|id| { + queue::pending::(e, id) + .is_some_and(|(change, _)| matches!(change, TokenChange::Upgrade(_))) + }) +} + +pub(crate) fn bind_vault(e: &Env, vault: &Address) { + setup::bind_source(e, vault).unwrap_or_else(|r| refuse(e, r)); + VaultBound { + vault: vault.clone(), + } + .publish(e); +} + +/// No nominee from setup may accept once changes wait, so a hand-over offered +/// during setup holds it open. +pub(crate) fn finish_setup(e: &Env) { + if setup::is_open(e) + && role_transfer::has_active_pending_transfer(e, &AccessControlStorageKey::PendingAdmin) + { + panic_with_error!(e, ShareTokenError::AdminTransferPending); + } + setup::finish::(e).unwrap_or_else(|r| refuse(e, r)); + SetupFinished {}.publish(e); +} + +pub(crate) fn propose(e: &Env, change: TokenChange) -> u32 { + if matches!(change, TokenChange::Upgrade(_)) && upgrade_pending(e) { + panic_with_error!(e, ShareTokenError::UpgradeAlreadyPending); + } + let id = queue::propose::(e, &change).unwrap_or_else(|r| refuse(e, r)); + let (_, schedule) = + queue::pending::(e, id).unwrap_or_else(|| refuse(e, Refusal::NotFound)); + // No pause has run since the proposal, so the eta needs no second clock read. + ChangeProposed { + id, + change, + eta: schedule.proposed_at.saturating_add(schedule.delay), + } + .publish(e); + id +} + +pub(crate) fn cancel(e: &Env, id: u32) { + queue::cancel(e, id).unwrap_or_else(|r| refuse(e, r)); + ChangeCancelled { id }.publish(e); +} + +pub(crate) fn apply(e: &Env, id: u32) { + let change: TokenChange = + queue::take_ready::(e, id).unwrap_or_else(|r| refuse(e, r)); + let admin = access_control::get_admin(e) + .unwrap_or_else(|| panic_with_error!(e, ShareTokenError::AdminRequired)); + match &change { + TokenChange::Upgrade(hash) => upgradeable::upgrade(e, hash), + TokenChange::GrantRole(role, account) => { + access_control::grant_role_no_auth(e, account, role, &admin) + } + TokenChange::RevokeRole(role, account) => { + access_control::revoke_role_no_auth(e, account, role, &admin) + } + TokenChange::Admin(next) => offer_admin(e, &admin, next), + TokenChange::Compliance(rules) => RWA::set_compliance(e, rules), + TokenChange::IdentityVerifier(verifier) => RWA::set_identity_verifier(e, verifier), + } + 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); +} + +/// Every change still waiting, with the moment it may land on the vault's +/// clock as it reads now. +pub(crate) fn pending(e: &Env) -> Vec { + let mut out = Vec::new(e); + let open = queue::open_ids(e); + if open.is_empty() { + // Skips the vault, which is not bound yet during setup. + return out; + } + let clock = vault_clock(e); + for id in open.iter() { + if let Some((change, schedule)) = queue::pending::(e, id) { + out.push_back(TokenPendingChange { + id, + change, + eta: schedule.eta(&clock), + }); + } + } + out +} diff --git a/contracts/share-token/src/lib.rs b/contracts/share-token/src/lib.rs index d4b250f..5d530e0 100644 --- a/contracts/share-token/src/lib.rs +++ b/contracts/share-token/src/lib.rs @@ -1,9 +1,15 @@ #![no_std] mod contract; mod error; +mod event; +mod governance; mod roles; pub use contract::*; pub use error::ShareTokenError; +pub use event::{ChangeApplied, ChangeCancelled, ChangeProposed, SetupFinished, VaultBound}; +pub use governance::{TokenChange, TokenPendingChange, ADMIN_ACCEPT_LEDGERS}; #[cfg(test)] mod test; +#[cfg(test)] +mod test_governance; diff --git a/contracts/share-token/src/roles.rs b/contracts/share-token/src/roles.rs index 16a3100..af118c8 100644 --- a/contracts/share-token/src/roles.rs +++ b/contracts/share-token/src/roles.rs @@ -1,4 +1,9 @@ +use soroban_sdk::{symbol_short, Symbol}; + /// Role for allowlist-driven interventions: freeze, forced transfer, recovery. /// Held by the compliance authority. Minting, burning and escrow use the /// `manager` role, which governance grants to the vault. pub const COMPLIANCE_ROLE: &str = "compliance"; + +/// The vault's role. Without it the vault cannot mint or burn, so no claim pays. +pub const MANAGER_ROLE: Symbol = symbol_short!("manager"); diff --git a/contracts/share-token/src/test_governance.rs b/contracts/share-token/src/test_governance.rs new file mode 100644 index 0000000..522a27a --- /dev/null +++ b/contracts/share-token/src/test_governance.rs @@ -0,0 +1,672 @@ +use soroban_sdk::{ + contract, contractimpl, symbol_short, + testutils::{ + storage::Instance as _, Address as _, Events as _, Ledger as _, MockAuth, MockAuthInvoke, + }, + Address, BytesN, Env, Event as _, IntoVal, String, Symbol, +}; +use stellar_access::role_transfer::RoleTransferError; +use storage::INSTANCE_EXTEND_AMOUNT; +use timelock::{Clock, DAY_DURATION, MIN_DELAY}; + +use crate::{ + ChangeApplied, ChangeProposed, ShareToken, ShareTokenClient, ShareTokenError, TokenChange, + ADMIN_ACCEPT_LEDGERS, +}; + +const TWO_DAYS_IN_LEDGERS: u32 = 2 * 17_280; + +/// Plays the vault: reports whatever clock a test sets, and counts the reads. +#[contract] +struct ClockStub; + +#[contractimpl] +impl ClockStub { + pub fn set(e: Env, clock: Clock) { + e.storage().instance().set(&symbol_short!("clock"), &clock); + } + + pub fn reads(e: Env) -> u32 { + e.storage() + .instance() + .get(&symbol_short!("reads")) + .unwrap_or(0) + } +} + +#[contractimpl(contracttrait)] +impl timelock::ClockSource for ClockStub { + fn timelock_clock(e: &Env) -> Clock { + let reads = Self::reads(e.clone()) + 1; + e.storage().instance().set(&symbol_short!("reads"), &reads); + e.storage().instance().get(&symbol_short!("clock")).unwrap() + } +} + +struct Fixture<'a> { + e: Env, + token: ShareTokenClient<'a>, + vault: ClockStubClient<'a>, + admin: Address, +} + +impl Fixture<'_> { + fn clock(&self, f: impl FnOnce(&mut Clock)) { + let mut clock = + timelock::ClockSourceClient::new(&self.e, &self.vault.address).timelock_clock(); + f(&mut clock); + self.vault.set(&clock); + } + + fn advance(&self, secs: u64) { + self.e.ledger().with_mut(|l| { + l.timestamp += secs; + l.sequence_number += (secs / 5).max(1) as u32; + }); + } + + fn finish(&self) { + self.token.bind_vault(&self.vault.address, &self.admin); + self.token.finish_setup(&self.admin); + } + + fn propose(&self, change: TokenChange) -> u32 { + self.token.propose_change(&change, &self.admin) + } + + /// Lands an `Admin` change for `next` and returns the ledger of the offer. + fn offer_admin(&self, next: &Address) -> u32 { + let id = self.propose(TokenChange::Admin(next.clone())); + self.advance(MIN_DELAY); + self.token.apply_change(&id, &self.admin); + self.e.ledger().sequence() + } + + fn instance_ttl(&self) -> u32 { + self.e.as_contract(&self.token.address, || { + self.e.storage().instance().get_ttl() + }) + } + + /// Lets the instance decay past its extend threshold, then checks the call + /// extends it back to the full TTL. + fn restores_the_instance(&self, call: impl FnOnce()) { + self.e + .ledger() + .with_mut(|l| l.sequence_number += TWO_DAYS_IN_LEDGERS); + assert!(self.instance_ttl() < INSTANCE_EXTEND_AMOUNT); + call(); + assert_eq!(self.instance_ttl(), INSTANCE_EXTEND_AMOUNT); + } +} + +fn setup<'a>() -> Fixture<'a> { + let e = Env::default(); + e.mock_all_auths(); + e.ledger().set_timestamp(10_000); + let admin = Address::generate(&e); + let token = e.register( + ShareToken, + ( + String::from_str(&e, "Strata Vault USDC"), + String::from_str(&e, "bvUSDC"), + admin.clone(), + Address::generate(&e), + Address::generate(&e), + Address::generate(&e), + ), + ); + let vault = ClockStubClient::new(&e, &e.register(ClockStub, ())); + vault.set(&Clock { + delay: MIN_DELAY, + exit_window: 0, + paused_secs: 0, + paused: false, + winding_down: false, + }); + Fixture { + token: ShareTokenClient::new(&e, &token), + vault, + admin, + e, + } +} + +fn refused( + result: Result, Result>, + expected: ShareTokenError, +) { + assert_eq!(result, Err(Ok(expected.into()))); +} + +fn manager() -> Symbol { + symbol_short!("manager") +} + +fn compliance(e: &Env) -> Symbol { + Symbol::new(e, "compliance") +} + +#[test] +fn setup_is_open_until_finished_and_never_reopens() { + let f = setup(); + assert!(f.token.setup_open()); + refused( + f.token.try_finish_setup(&f.admin), + ShareTokenError::VaultNotBound, + ); + + f.finish(); + + assert!(!f.token.setup_open()); + assert_eq!(f.token.vault(), Some(f.vault.address.clone())); + refused( + f.token.try_finish_setup(&f.admin), + ShareTokenError::SetupClosed, + ); + refused( + f.token.try_bind_vault(&Address::generate(&f.e), &f.admin), + ShareTokenError::SetupClosed, + ); +} + +#[test] +fn a_second_vault_cannot_be_bound() { + let f = setup(); + f.token.bind_vault(&f.vault.address, &f.admin); + + refused( + f.token.try_bind_vault(&Address::generate(&f.e), &f.admin), + ShareTokenError::SetupClosed, + ); + assert_eq!(f.token.vault(), Some(f.vault.address.clone())); +} + +#[test] +fn an_address_that_reports_no_clock_cannot_be_bound() { + let f = setup(); + + assert!(f + .token + .try_bind_vault(&Address::generate(&f.e), &f.admin) + .is_err()); + assert_eq!(f.token.vault(), None); +} + +#[test] +fn only_the_admin_binds_and_finishes() { + let f = setup(); + let stranger = Address::generate(&f.e); + f.e.set_auths(&[]); + + assert!(f.token.try_bind_vault(&f.vault.address, &stranger).is_err()); + assert!(f.token.try_finish_setup(&stranger).is_err()); + assert!(f.token.setup_open()); +} + +fn implements_the_vault_view() {} + +#[test] +fn the_token_answers_the_view_the_vault_reads() { + implements_the_vault_view::(); + let f = setup(); + let view = bindings::VaultBindingClient::new(&f.e, &f.token.address); + assert_eq!(view.vault(), None); + assert!(view.setup_open()); + + f.finish(); + + assert_eq!(view.vault(), Some(f.vault.address.clone())); + assert!(!view.setup_open()); +} + +#[test] +fn grants_and_manager_revokes_wait_after_setup() { + let f = setup(); + let early = Address::generate(&f.e); + f.token.grant_role(&early, &manager(), &f.admin); + assert!(f.token.has_role(&early, &manager()).is_some()); + + f.finish(); + + refused( + f.token + .try_grant_role(&Address::generate(&f.e), &manager(), &f.admin), + ShareTokenError::UseTimelock, + ); + refused( + f.token.try_revoke_role(&early, &manager(), &f.admin), + ShareTokenError::UseTimelock, + ); + assert!(f.token.has_role(&early, &manager()).is_some()); +} + +#[test] +fn rule_and_verifier_swaps_wait_after_setup() { + let f = setup(); + f.finish(); + + refused( + f.token + .try_set_compliance(&Address::generate(&f.e), &f.admin), + ShareTokenError::UseTimelock, + ); + refused( + f.token + .try_set_identity_verifier(&Address::generate(&f.e), &f.admin), + ShareTokenError::UseTimelock, + ); +} + +#[test] +fn revoking_compliance_stays_instant_after_setup() { + let f = setup(); + let officer = Address::generate(&f.e); + f.token.grant_role(&officer, &compliance(&f.e), &f.admin); + f.finish(); + + f.token.revoke_role(&officer, &compliance(&f.e), &f.admin); + + assert!(f.token.has_role(&officer, &compliance(&f.e)).is_none()); +} + +#[test] +fn an_admin_hand_over_waits_but_cancelling_one_does_not() { + let f = setup(); + f.finish(); + let next = Address::generate(&f.e); + + refused( + f.token + .try_transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)), + ShareTokenError::UseTimelock, + ); + + let id = f.propose(TokenChange::Admin(next.clone())); + f.advance(MIN_DELAY); + f.token.apply_change(&id, &f.admin); + f.token.transfer_admin_role(&next, &0); + + assert!(f.token.try_accept_admin_transfer().is_err()); + assert_eq!(f.token.get_admin(), Some(f.admin.clone())); +} + +#[test] +fn setup_cannot_finish_while_a_hand_over_is_pending() { + let f = setup(); + let next = Address::generate(&f.e); + f.token.bind_vault(&f.vault.address, &f.admin); + f.token + .transfer_admin_role(&next, &(f.e.ledger().sequence() + 100)); + + refused( + f.token.try_finish_setup(&f.admin), + ShareTokenError::AdminTransferPending, + ); + + f.token.transfer_admin_role(&next, &0); + f.token.finish_setup(&f.admin); + assert!(f.token.try_accept_admin_transfer().is_err()); + assert_eq!(f.token.get_admin(), Some(f.admin.clone())); +} + +#[test] +fn an_expired_hand_over_does_not_hold_setup_open() { + let f = setup(); + f.token.bind_vault(&f.vault.address, &f.admin); + f.token + .transfer_admin_role(&Address::generate(&f.e), &(f.e.ledger().sequence() + 10)); + f.e.ledger().with_mut(|l| l.sequence_number += 11); + + f.token.finish_setup(&f.admin); + + assert!(!f.token.setup_open()); + assert_eq!( + f.token.try_accept_admin_transfer(), + Err(Ok(RoleTransferError::NoPendingTransfer.into())) + ); + assert_eq!(f.token.get_admin(), Some(f.admin.clone())); +} + +#[test] +fn the_token_exposes_no_immediate_upgrade() { + let f = setup(); + f.finish(); + let upgrader = + stellar_contract_utils::upgradeable::UpgradeableClient::new(&f.e, &f.token.address); + + assert!(upgrader + .try_upgrade(&BytesN::from_array(&f.e, &[7; 32]), &f.admin) + .is_err()); + assert_eq!(f.token.schema_version(), 1); +} + +#[test] +fn no_role_ever_gets_a_role_admin() { + let f = setup(); + + refused( + f.token + .try_set_role_admin(&manager(), &symbol_short!("guardian")), + ShareTokenError::UseTimelock, + ); +} + +#[test] +fn nothing_is_proposed_during_setup() { + let f = setup(); + + refused( + f.token.try_propose_change( + &TokenChange::GrantRole(manager(), f.admin.clone()), + &f.admin, + ), + ShareTokenError::SetupOpen, + ); + assert_eq!(f.token.pending_changes().len(), 0); +} + +#[test] +fn a_grant_lands_one_vault_delay_after_it_was_proposed() { + let f = setup(); + f.finish(); + let next = Address::generate(&f.e); + + let id = f.propose(TokenChange::GrantRole(manager(), next.clone())); + let pending = f.token.pending_changes(); + assert_eq!(pending.len(), 1); + assert_eq!( + pending.get(0).unwrap().eta, + f.e.ledger().timestamp() + MIN_DELAY + ); + + f.advance(MIN_DELAY - 1); + refused( + f.token.try_apply_change(&id, &f.admin), + ShareTokenError::ChangeNotReady, + ); + + f.advance(1); + f.token.apply_change(&id, &f.admin); + assert!(f.token.has_role(&next, &manager()).is_some()); + assert_eq!(f.token.pending_changes().len(), 0); +} + +#[test] +fn a_revoke_lands_through_the_timelock() { + let f = setup(); + let holder = Address::generate(&f.e); + f.token.grant_role(&holder, &manager(), &f.admin); + f.finish(); + + let id = f.propose(TokenChange::RevokeRole(manager(), holder.clone())); + f.advance(MIN_DELAY); + f.token.apply_change(&id, &f.admin); + + assert!(f.token.has_role(&holder, &manager()).is_none()); +} + +#[test] +fn a_vault_pause_moves_the_eta_and_holds_the_change() { + let f = setup(); + f.finish(); + let id = f.propose(TokenChange::Compliance(Address::generate(&f.e))); + let eta = f.token.pending_changes().get(0).unwrap().eta; + + f.advance(DAY_DURATION); + f.clock(|c| c.paused = true); + f.advance(3 * DAY_DURATION); + f.clock(|c| c.paused_secs = 3 * DAY_DURATION); + f.advance(MIN_DELAY); + + refused( + f.token.try_apply_change(&id, &f.admin), + ShareTokenError::VaultPaused, + ); + + f.clock(|c| c.paused = false); + assert_eq!( + f.token.pending_changes().get(0).unwrap().eta, + eta + 3 * DAY_DURATION + ); + f.advance(3 * DAY_DURATION); + f.token.apply_change(&id, &f.admin); +} + +#[test] +fn nothing_is_proposed_or_applied_while_the_vault_winds_down() { + let f = setup(); + f.finish(); + let id = f.propose(TokenChange::Compliance(Address::generate(&f.e))); + f.clock(|c| c.winding_down = true); + f.advance(MIN_DELAY); + + refused( + f.token + .try_propose_change(&TokenChange::Compliance(Address::generate(&f.e)), &f.admin), + ShareTokenError::WindDownActive, + ); + refused( + f.token.try_apply_change(&id, &f.admin), + ShareTokenError::WindDownActive, + ); + f.token.cancel_change(&id, &f.admin); +} + +#[test] +fn a_change_shorter_than_a_raised_notice_never_lands() { + let f = setup(); + f.finish(); + let id = f.propose(TokenChange::Compliance(Address::generate(&f.e))); + f.clock(|c| { + c.delay = 30 * DAY_DURATION; + c.exit_window = 20 * DAY_DURATION; + }); + f.advance(30 * DAY_DURATION); + + refused( + f.token.try_apply_change(&id, &f.admin), + ShareTokenError::DelayBelowNotice, + ); +} + +#[test] +fn one_upgrade_waits_at_a_time() { + let f = setup(); + f.finish(); + f.propose(TokenChange::Upgrade(BytesN::from_array(&f.e, &[7; 32]))); + + refused( + f.token.try_propose_change( + &TokenChange::Upgrade(BytesN::from_array(&f.e, &[9; 32])), + &f.admin, + ), + ShareTokenError::UpgradeAlreadyPending, + ); +} + +#[test] +fn only_the_admin_proposes_cancels_and_applies() { + let f = setup(); + f.finish(); + let stranger = Address::generate(&f.e); + let change = TokenChange::Compliance(Address::generate(&f.e)); + let id = f.propose(change.clone()); + f.advance(MIN_DELAY); + + f.e.set_auths(&[]); + assert!(f.token.try_propose_change(&change, &stranger).is_err()); + assert!(f.token.try_cancel_change(&id, &stranger).is_err()); + assert!(f.token.try_apply_change(&id, &stranger).is_err()); + assert_eq!(f.token.pending_changes().len(), 1); +} + +#[test] +fn an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts() { + let f = setup(); + f.finish(); + let next = Address::generate(&f.e); + let id = f.propose(TokenChange::Admin(next.clone())); + f.advance(MIN_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.token.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.token.apply_change(&id, &f.admin); + + f.e.mock_all_auths(); + assert_eq!(f.token.get_admin(), Some(f.admin.clone())); + f.token.accept_admin_transfer(); + assert_eq!(f.token.get_admin(), Some(next)); +} + +#[test] +fn the_hand_over_offer_is_open_on_its_last_ledger() { + let f = setup(); + f.finish(); + let next = Address::generate(&f.e); + let offered_at = f.offer_admin(&next); + + f.e.ledger() + .with_mut(|l| l.sequence_number = offered_at + ADMIN_ACCEPT_LEDGERS); + f.token.accept_admin_transfer(); + + assert_eq!(f.token.get_admin(), Some(next)); +} + +#[test] +fn the_hand_over_offer_lasts_seven_days_of_ledgers() { + let f = setup(); + f.finish(); + let offered_at = f.offer_admin(&Address::generate(&f.e)); + + f.e.ledger() + .with_mut(|l| l.sequence_number = offered_at + ADMIN_ACCEPT_LEDGERS + 1); + + assert_eq!( + f.token.try_accept_admin_transfer(), + Err(Ok(RoleTransferError::NoPendingTransfer.into())) + ); + assert_eq!(f.token.get_admin(), Some(f.admin.clone())); +} + +#[test] +fn applied_rule_and_verifier_changes_take_effect() { + let f = setup(); + f.finish(); + let rules = Address::generate(&f.e); + let verifier = Address::generate(&f.e); + let first = f.propose(TokenChange::Compliance(rules.clone())); + let second = f.propose(TokenChange::IdentityVerifier(verifier.clone())); + f.advance(MIN_DELAY); + + f.token.apply_change(&first, &f.admin); + f.token.apply_change(&second, &f.admin); + + assert_eq!(f.token.compliance(), rules); + assert_eq!(f.token.identity_verifier(), verifier); +} + +#[test] +fn proposing_and_applying_publish_the_change() { + let f = setup(); + f.finish(); + let change = TokenChange::Compliance(Address::generate(&f.e)); + let eta = f.e.ledger().timestamp() + MIN_DELAY; + + let id = f.propose(change.clone()); + assert_eq!( + f.e.events().all().filter_by_contract(&f.token.address), + [ChangeProposed { + id, + change: change.clone(), + eta, + } + .to_xdr(&f.e, &f.token.address)] + ); + + f.advance(MIN_DELAY); + f.token.apply_change(&id, &f.admin); + let events = f.e.events().all().filter_by_contract(&f.token.address); + assert_eq!( + events.events().last(), + Some(&ChangeApplied { id, change }.to_xdr(&f.e, &f.token.address)) + ); +} + +#[test] +fn proposing_reads_the_vault_clock_once() { + let f = setup(); + f.finish(); + let before = f.vault.reads(); + + f.propose(TokenChange::Compliance(Address::generate(&f.e))); + + assert_eq!(f.vault.reads(), before + 1); +} + +#[test] +fn a_proposal_after_a_past_pause_waits_one_delay_from_now() { + let f = setup(); + f.finish(); + f.clock(|c| c.paused_secs = 3 * DAY_DURATION); + let change = TokenChange::Compliance(Address::generate(&f.e)); + let eta = f.e.ledger().timestamp() + MIN_DELAY; + + let id = f.propose(change.clone()); + + assert_eq!( + f.e.events().all().filter_by_contract(&f.token.address), + [ChangeProposed { id, change, eta }.to_xdr(&f.e, &f.token.address)] + ); + assert_eq!(f.token.pending_changes().get(0).unwrap().eta, eta); +} + +#[test] +fn the_schema_version_is_one_after_construction() { + let f = setup(); + + assert_eq!(f.token.schema_version(), 1); +} + +#[test] +fn every_admin_call_restores_the_instance_ttl() { + let f = setup(); + let holder = Address::generate(&f.e); + let next = Address::generate(&f.e); + + f.restores_the_instance(|| f.token.grant_role(&holder, &manager(), &f.admin)); + f.restores_the_instance(|| f.token.revoke_role(&holder, &manager(), &f.admin)); + f.restores_the_instance(|| { + f.token + .transfer_admin_role(&next, &(f.e.ledger().sequence() + ADMIN_ACCEPT_LEDGERS)) + }); + f.restores_the_instance(|| f.token.transfer_admin_role(&next, &0)); + f.restores_the_instance(|| f.token.bind_vault(&f.vault.address, &f.admin)); + f.restores_the_instance(|| f.token.finish_setup(&f.admin)); + + let rules = TokenChange::Compliance(Address::generate(&f.e)); + let mut id = 0; + f.restores_the_instance(|| id = f.propose(rules.clone())); + f.restores_the_instance(|| f.token.cancel_change(&id, &f.admin)); + + let id = f.propose(rules); + f.advance(MIN_DELAY); + f.restores_the_instance(|| f.token.apply_change(&id, &f.admin)); +} diff --git a/crates/bindings/src/lib.rs b/crates/bindings/src/lib.rs index 7452dc1..eee072f 100644 --- a/crates/bindings/src/lib.rs +++ b/crates/bindings/src/lib.rs @@ -1,6 +1,6 @@ #![no_std] -use soroban_sdk::{contracttrait, contracttype, Env}; +use soroban_sdk::{contracttrait, contracttype, Address, Env}; #[contracttype] #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -21,3 +21,12 @@ pub trait OracleFeed { /// How long a report stays valid after it is accepted. fn freshness_duration(e: &Env) -> u64; } + +/// What a contract that runs on a vault's timelock reports to that vault. +#[contracttrait] +pub trait VaultBinding { + /// The vault whose clock this contract reads, once bound. + fn vault(e: &Env) -> Option
; + /// Whether this contract's admin changes are still instant. + fn setup_open(e: &Env) -> bool; +} diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index 20198a0..8d24c7a 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -36,6 +36,7 @@ In `scripts/harness/deploy.ts` and automated deployment scripts, the separation - 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. +- 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). --- @@ -94,9 +95,9 @@ Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs. ### 5.2 ShareToken (`contracts/share-token`) -The token's `admin` is governance. `pause`, `unpause`, `set_compliance` and `set_identity_verifier` need the admin; the guardian holds no role on the token. The `manager` role is held by the vault and the `compliance` role by the compliance authority. +The token's `admin` is governance; the guardian holds no role on the token. Until `finish_setup`, admin changes are instant, so the deploy can grant the vault the `manager` role. Setup cannot finish while an admin hand-over offered during setup is pending. After setup, the following wait for the vault's timelock through `propose_change`/`apply_change`: grants, revoking `manager`, admin hand-over, `set_compliance`, `set_identity_verifier` and upgrades. Revoking `compliance` and `pause`/`unpause` stay instant. The `manager` role is held by the vault and the `compliance` role by the compliance authority. -Tests: [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs). +Tests: [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs) and [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs). | Entrypoint | Access Control / Caller Auth | State & Precondition Guards | Tested Refusal (Test Name) | |---|---|---|---| @@ -109,9 +110,20 @@ Tests: [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.r | `freeze_partial_tokens` | `#[only_role(operator, "compliance")]` | Balance sufficient | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_manager_escrows_but_does_not_intervene` | | `unfreeze_partial_tokens`| `#[only_role(operator, "compliance")]` | Frozen amount sufficient | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_manager_escrows_but_does_not_intervene` | | `recover_balance` | `#[only_role(operator, "compliance")]` | Target allowlisted and named by the verifier's `recovery_target` (the testnet stub names none, so recovery always fails there) | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_compliance_authority_reaches_recovery` | -| `set_compliance` | `#[only_admin]`, `operator.require_auth()` | None | `test::unauthorized_caller_cannot_set_compliance_or_verifier`, `test::the_compliance_authority_cannot_mint_burn_or_swap_the_rules` | -| `set_identity_verifier` | `#[only_admin]`, `operator.require_auth()` | None | `test::unauthorized_caller_cannot_set_compliance_or_verifier`, `test::the_compliance_authority_cannot_mint_burn_or_swap_the_rules` | -| `renounce_admin` | Refused always (`ShareTokenError::AdminRequired`) | None | `test::the_admin_cannot_renounce_itself_out_of_the_token`; `test::admin_handover_in_two_steps_still_works`. A token without an admin could never be unpaused or rotate its manager; hand over through `transfer_admin_role` and `accept_admin_transfer`. | +| `set_compliance` | `#[only_admin]`, `operator.require_auth()` | Instant during setup; `UseTimelock` after | `test::unauthorized_caller_cannot_set_compliance_or_verifier`, `test::the_compliance_authority_cannot_mint_burn_or_swap_the_rules`, `test_governance::rule_and_verifier_swaps_wait_after_setup` | +| `set_identity_verifier` | `#[only_admin]`, `operator.require_auth()` | Instant during setup; `UseTimelock` after | `test::unauthorized_caller_cannot_set_compliance_or_verifier`, `test::the_compliance_authority_cannot_mint_burn_or_swap_the_rules`, `test_governance::rule_and_verifier_swaps_wait_after_setup` | +| `renounce_admin` | Refused always (`ShareTokenError::AdminRequired`) | None | `test::the_admin_cannot_renounce_itself_out_of_the_token`; `test::admin_handover_in_two_steps_still_works`; `test_governance::an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts`. A token without an admin could never be unpaused or rotate its manager; hand over by proposing and applying an `Admin` change (`transfer_admin_role` during setup), then the nominee calls `accept_admin_transfer`. | +| `bind_vault` | `#[only_admin]`, `caller.require_auth()` | During setup, once; the vault must answer `timelock_clock` | `test_governance::a_second_vault_cannot_be_bound`, `test_governance::an_address_that_reports_no_clock_cannot_be_bound` | +| `finish_setup` | `#[only_admin]`, `caller.require_auth()` | Needs a bound vault and no pending admin hand-over; once | `test_governance::setup_is_open_until_finished_and_never_reopens`, `test_governance::setup_cannot_finish_while_a_hand_over_is_pending` | +| `propose_change` | `#[only_admin]`, `caller.require_auth()` | After setup; vault not winding down; one upgrade at a time | `test_governance::nothing_is_proposed_during_setup`, `test_governance::only_the_admin_proposes_cancels_and_applies` | +| `cancel_change` | `#[only_admin]`, `caller.require_auth()` | The change must be pending | `test_governance::only_the_admin_proposes_cancels_and_applies` | +| `apply_change` | `#[only_admin]`, `caller.require_auth()` | Delay run on the vault's clock, vault unpaused and not winding down, delay `>=` the vault's notice | `test_governance::a_vault_pause_moves_the_eta_and_holds_the_change`, `test_governance::a_change_shorter_than_a_raised_notice_never_lands` | +| `pending_changes` | Open view | None | None (view) | +| `grant_role` | Admin, `caller.require_auth()` | Instant during setup; `UseTimelock` after | `test_governance::grants_and_manager_revokes_wait_after_setup` | +| `revoke_role` | Admin, `caller.require_auth()` | `compliance`: instant. `manager`: instant during setup, `UseTimelock` after | `test_governance::grants_and_manager_revokes_wait_after_setup` (the instant case: `test_governance::revoking_compliance_stays_instant_after_setup`) | +| `transfer_admin_role` | Admin | Instant during setup; `UseTimelock` after. A zero `live_until_ledger` cancels at once | `test_governance::an_admin_hand_over_waits_but_cancelling_one_does_not` | +| `set_role_admin` | Refused always (`ShareTokenError::UseTimelock`) | None | `test_governance::no_role_ever_gets_a_role_admin`. A role admin could grant its role with no delay. | +| `upgrade` | Not exposed | Upgrades go through `propose_change(Upgrade)` | `test_governance::the_token_exposes_no_immediate_upgrade` | ### 5.3 NavOracle (`contracts/nav-oracle`) diff --git a/docs/TESTING.md b/docs/TESTING.md index 4d70063..c977814 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -11,9 +11,18 @@ SOROBAN_SDK_BUILD_SYSTEM_SUPPORTS_SPEC_SHAKING_V2=1 nix develop -c cargo test ``` The variable is needed whenever `cargo` runs directly (see -[AGENTS.md](../AGENTS.md)). The tests that upgrade the vault to a real WASM -build run only with `--features upgrade_wasm`, after the fixture in -[`fixtures/upgrade-target`](../fixtures/upgrade-target) is built. +[AGENTS.md](../AGENTS.md)). The tests that upgrade the vault and the share +token to a real WASM build run only with `--features upgrade_wasm`, after three +builds: the fixture in [`fixtures/upgrade-target`](../fixtures/upgrade-target), +the share token, and the share token's probe build, a stand-in for its next +version. Inside `nix develop`: + +```sh +stellar contract build --manifest-path fixtures/upgrade-target/Cargo.toml +stellar contract build --package share-token +CARGO_TARGET_DIR=target/upgrade-probe stellar contract build --package share-token --features upgrade_probe +SOROBAN_SDK_BUILD_SYSTEM_SUPPORTS_SPEC_SHAKING_V2=1 cargo test -p async-vault --features upgrade_wasm with_wasm +``` ## Storage and TTL @@ -29,7 +38,7 @@ contract extends, who pays, and how to restore an archived one. | Persistent entries round-trip and get the full TTL on write | [`crates/storage/src/test.rs`](../crates/storage/src/test.rs) | `persistent_round_trips`, `writing_sets_the_full_ttl` | | A read restores a decayed entry; a missing entry is not created | [`crates/storage/src/test.rs`](../crates/storage/src/test.rs) | `reading_a_decayed_entry_restores_the_full_ttl`, `reading_a_missing_entry_creates_nothing` | | Instance writes and reads extend the contract instance | [`crates/storage/src/test.rs`](../crates/storage/src/test.rs) | `instance_writes_bump_the_instance`, `reading_the_instance_restores_its_ttl` | -| The share token, identity verifier and compliance contract extend their own instance on every call that does work | [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs), [`contracts/identity-verifier/src/test.rs`](../contracts/identity-verifier/src/test.rs), [`contracts/compliance/src/test.rs`](../contracts/compliance/src/test.rs) | `a_transfer_restores_the_instance_ttl`, `the_vaults_mint_burn_and_escrow_restore_the_instance_ttl`, `allowing_and_checking_restore_the_instance_ttl`, `the_hooks_a_token_calls_after_moving_shares_restore_the_instance_ttl` | +| The share token, identity verifier and compliance contract extend their own instance on every call that does work | [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs), [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs), [`contracts/identity-verifier/src/test.rs`](../contracts/identity-verifier/src/test.rs), [`contracts/compliance/src/test.rs`](../contracts/compliance/src/test.rs) | `a_transfer_restores_the_instance_ttl`, `the_vaults_mint_burn_and_escrow_restore_the_instance_ttl`, `every_admin_call_restores_the_instance_ttl`, `allowing_and_checking_restore_the_instance_ttl`, `the_hooks_a_token_calls_after_moving_shares_restore_the_instance_ttl` | | Vault requests and epochs are written with the full TTL | [`contracts/async-vault/src/test/ttl.rs`](../contracts/async-vault/src/test/ttl.rs) | `a_request_and_its_epoch_are_written_with_the_full_ttl`, `a_redemption_request_is_written_with_the_full_ttl` | | Reading a vault request, epoch or the instance restores its TTL | [`contracts/async-vault/src/test/ttl.rs`](../contracts/async-vault/src/test/ttl.rs) | `reading_a_decayed_request_restores_its_full_ttl`, `reading_a_decayed_epoch_restores_its_full_ttl`, `any_vault_read_restores_the_instance_ttl` | | A request still claims, and a wind-down position still pays, after most of a month | [`contracts/async-vault/src/test/ttl.rs`](../contracts/async-vault/src/test/ttl.rs), [`contracts/async-vault/src/test/wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs) | `a_pending_request_still_claims_after_most_of_a_month`, `a_position_survives_the_ledger_advancing` | @@ -59,6 +68,7 @@ nothing. | Guardian, governance and upgrade powers stay with their role | [`contracts/async-vault/src/test/controls.rs`](../contracts/async-vault/src/test/controls.rs), [`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) | `the_guardian_pauses_but_only_governance_unpauses`, `only_governance_proposes_and_cancels`, `only_governance_applies`, `only_governance_sets_the_delay` | | Roles that must stay separate are refused at deployment | [`contracts/async-vault/src/test/constructor.rs`](../contracts/async-vault/src/test/constructor.rs) | `the_treasury_may_not_also_be_the_guardian`, `compliance_may_not_also_be_governance` | | Share token: only the right role mints, burns, pauses, freezes or moves shares | [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs) | `unauthorized_caller_cannot_mint`, `unauthorized_caller_cannot_burn`, `unauthorized_caller_cannot_forced_transfer`, `unauthorized_caller_cannot_freeze_or_recover` | +| 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` | | Identity verifier: only the admin edits the allowlist | [`contracts/identity-verifier/src/test.rs`](../contracts/identity-verifier/src/test.rs) | `only_admin_can_allow` | @@ -74,6 +84,7 @@ so time rules and TTL can be reached in one test. | A valuation must be newer than the epoch close | [`contracts/async-vault/src/test/oracle_pricing.rs`](../contracts/async-vault/src/test/oracle_pricing.rs) | `an_epoch_is_not_priced_against_a_valuation_older_than_its_close`, `a_valuation_taken_in_the_closing_ledger_prices_the_epoch` | | Oracle freshness, cooldown and its own timestamp | [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | `heartbeat_expiry_makes_it_stale`, `attest_enforces_cooldown_and_deviation`, `attest_stamps_its_own_timestamp`, `a_reporter_expiry_past_the_window_is_cut_to_the_window`, `two_reports_in_one_ledger_are_refused_at_the_shortest_cooldown` | | 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` | | 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 @@ -96,4 +107,5 @@ 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` | -| 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_proposal_replaces_the_code`, `a_migration_refuses_a_second_run` | +| 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 | [`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` | diff --git a/docs/kit-spec.md b/docs/kit-spec.md index a65727d..62b3e86 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -91,6 +91,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - Nothing is proposed or applied during an active wind-down (`WindDownActive`). - One code change (upgrade or upgrade delay) waits at a time (`UpgradeProposalExists`). - Renouncing admin is permanently refused (`AdminRequired`). + - Once its one-time setup closes, the share token's upgrades, grants, `manager` revocations, admin hand-overs and compliance or verifier swaps wait on the vault's clock under the same rules; a direct call is refused with `UseTimelock`. Revoking `compliance` stays instant. ### Invariant 9: Wind-Down Pro-Rata Solvency - **Definition**: In an active wind-down, total round payouts across all share claims can never exceed the capital credited to that round. @@ -220,7 +221,16 @@ Strata distributes control across five native Stellar multisig authorities and p | **`ShareToken`** | `burn` | Manager (Vault) | `#[only_role(operator, "manager")]` | Bypasses token pause | | **`ShareToken`** | `forced_transfer` | Manager (Vault) or Compliance | `#[only_any_role(operator, ["manager", "compliance"])]` | Bypasses token pause | | **`ShareToken`** | `set_address_frozen`, `freeze_partial_tokens`, `unfreeze_partial_tokens`, `recover_balance` | Compliance | `#[only_role(operator, "compliance")]` | No | -| **`ShareToken`** | `set_compliance`, `set_identity_verifier` | Governance | `#[only_admin]` | No | +| **`ShareToken`** | `set_compliance`, `set_identity_verifier` | Governance | `#[only_admin]`; instant during setup, then `propose_change` | No | +| **`ShareToken`** | `bind_vault` | Governance (token admin) | `#[only_admin]`; during setup, once | No | +| **`ShareToken`** | `finish_setup` | Governance | `#[only_admin]`; needs a bound vault and no pending admin hand-over | No | +| **`ShareToken`** | `propose_change` | Governance | `#[only_admin]`; after setup | No | +| **`ShareToken`** | `cancel_change` | Governance | `#[only_admin]` | No | +| **`ShareToken`** | `apply_change` | Governance | `#[only_admin]`; after the vault's delay | Refused while the vault is paused or winding down | +| **`ShareToken`** | `pending_changes` | Permissionless | None (view) | No | +| **`ShareToken`** | `grant_role`, `transfer_admin_role` | Governance | Instant during setup, then `propose_change` (a zero `live_until_ledger` cancels at once) | No | +| **`ShareToken`** | `revoke_role` | Governance | `compliance`: instant. `manager`: instant during setup, then `propose_change` | No | +| **`ShareToken`** | `set_role_admin` | None | Always refused (`UseTimelock`) | N/A | | **`IdentityVerifier`** | `allow` | Admin (Compliance) | `#[only_admin]` | No | | **`IdentityVerifier`** | `set_claim_topics_and_issuers`| Admin (Compliance) | `#[only_admin]` | No | @@ -234,6 +244,7 @@ A clean deployment executes in three sequenced phases: - Deploy `compliance`, `identity_verifier`, `share_token`, `nav_oracle`, and `async_vault` with constructor arguments matching `environments.toml`. 2. **Phase 2: Post-Deployment Role Wiring** - Call `ShareToken::grant_role("manager", vault_address)` to authorize lazy share minting and burning. + - Call `ShareToken::bind_vault(vault_address)`, then `ShareToken::finish_setup()`. The token's admin changes are instant until then and wait for the vault's timelock afterwards. - 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. 3. **Phase 3: Operational Parameterization** @@ -253,7 +264,7 @@ instance is archived refuses every call. |---|---|---|---| | `async_vault` | Its instance, and every persistent entry it reads or writes: epochs, requests, settlements, wind-down positions, pending governance changes | Every call that reads or writes the entry | 30 days (518,400 ledgers), once less than 29 days remain | | `nav_oracle` | Its instance and the latest report | `attest` extends both; every other call extends whichever of the two it reads or writes | Same | -| `share_token` | Its instance | Every call that moves shares or changes token settings: `transfer`, `transfer_from`, `approve`, `mint`, `burn`, `forced_transfer`, freezes, recovery, `set_compliance`, `set_identity_verifier`, `pause`, `unpause` | Same | +| `share_token` | Its instance, and each pending governance change it reads or writes | Every call that moves shares or changes token settings: `transfer`, `transfer_from`, `approve`, `mint`, `burn`, `forced_transfer`, freezes, recovery, `set_compliance`, `set_identity_verifier`, `pause`, `unpause`, `grant_role`, `revoke_role`, `transfer_admin_role`, `bind_vault`, `finish_setup`, `propose_change`, `cancel_change`, `apply_change` | Same | | `identity_verifier` | Its instance and each allowlist entry | `allow`, `is_allowed`, `verify_identity` | Same | | `compliance` | Its instance | Every hook the token calls (`can_create`, `can_transfer`, `created`, `transferred`, `destroyed`) and its admin calls | Same | @@ -262,15 +273,16 @@ The lengths are the constants in `crates/storage` shorter). They are fixed in code: a governance setting would add an admin power for little gain. -**Pending governance changes.** A change waiting in `async_vault`'s timelock -lives as a persistent entry, extended to 30 days when it is proposed. The wait -itself can run up to 90 days (`timelock::MAX_DELAY`), so a change nobody -touches can archive before it is due. Nothing is lost: the call that applies -it restores the archived entry itself and pays the restore fee, the same as -any other archived entry. `npm run extend` also keeps it alive: it reads the -vault's open ids from its `pending_changes()` view, builds each one's storage -key the way the timelock crate does, and extends it the same 30 days as every -instance and Wasm. +**Pending governance changes.** A change waiting in the timelock of +`async_vault` or `share_token` lives as a persistent entry, extended to 30 days +when it is proposed. The wait itself can run up to 90 days +(`timelock::MAX_DELAY`), so a change nobody touches can archive before it is +due. Nothing is lost: the call that applies it restores the archived entry +itself and pays the restore fee, the same as any other archived entry. +`npm run extend` also keeps it alive: it reads each contract's open ids from +its `pending_changes()` view, builds each one's storage key the way the +timelock crate does, and extends it the same 30 days as every instance and +Wasm. **What OpenZeppelin extends.** `stellar-tokens` 0.7.2 stores the share token's balances and frozen amounts, and the compliance contract's modules and bound @@ -321,3 +333,9 @@ implementation against what the vault calls. A feed that implements `OracleFeed` can replace `nav-oracle`; it must report `Stale` once its report outlives `freshness_duration`, and `attested_at` must be the ledger time the report was accepted, not a time the reporter supplies. + +Two more traits connect the share token to the vault's timelock. The vault +implements `ClockSource` (`crates/timelock`), and the share token reads its +`timelock_clock` to time its own changes. The share token implements +`VaultBinding` (`crates/bindings`): `vault` names the vault it is bound to, and +`setup_open` says whether its admin changes are still instant. diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index 29664a3..5ba6595 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -285,9 +285,9 @@ number with a proof reference. | Component | Role | Controls | | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Vault** | Request lifecycle, epoch sealing by its manager role, pricing, split reserve accounting, custodian transfers, upgrade control | One authority per privileged entrypoint; upgrades behind a governance timelock no shorter than the notice, bounded between 7 and 90 days. Administrative handover follows a strict two-step transfer pattern; renouncing admin is permanently refused (`AdminRequired`) | -| **Share token** | OZ SEP-41 + SEP-57 RWA extensions: freeze, forced transfer, recovery, identity and compliance checks on every transfer, independent transfer pause (admin-gated; governance by deploy) | Manager role held only by the vault, for mint, burn and escrow; compliance role held by the compliance authority, for freeze, forced transfer and recovery; swapping the compliance or identity-verifier contract needs the admin | -| **Token roles** | Access-control roles on the share token, checked directly via native access control: `manager` mints, burns and escrows; `compliance` freezes, forces transfers and recovers | Granted and revoked by the token admin (governance by deploy) | +| **Vault** | Request lifecycle, epoch sealing by its manager role, pricing, split reserve accounting, custodian transfers, upgrade control | One authority per privileged entrypoint; upgrades behind a governance timelock no shorter than the notice, bounded between 7 and 90 days. The share token upgrades under the same timelock. Administrative handover follows a strict two-step transfer pattern; renouncing admin is permanently refused (`AdminRequired`) | +| **Share token** | OZ SEP-41 + SEP-57 RWA extensions: freeze, forced transfer, recovery, identity and compliance checks on every transfer, independent transfer pause (admin-gated; governance by deploy) | Manager role held only by the vault, for mint, burn and escrow; compliance role held by the compliance authority, for freeze, forced transfer and recovery; swapping the compliance or identity-verifier contract needs the admin and, after a one-time setup, waits for the vault's timelock | +| **Token roles** | Access-control roles on the share token, checked directly via native access control: `manager` mints, burns and escrows; `compliance` freezes, forces transfers and recovers | Granted and revoked by the token admin (governance by deploy). After a one-time setup, a grant, revoking `manager` and an admin handover wait for the vault's timelock; revoking `compliance` stays instant | | **Compliance module** | Implements the SEP-57 identity and rules interfaces the share token consults, with the allowlist as its only rule | Managed by the compliance authority via direct role access; replaceable by OZ's identity verifier and compliance contracts (with RWA Wizard modules) without touching the token | | **Authorities** | governance (parameters, roles, timelocked upgrades), compliance (allowlist, token interventions), attestation (valuation only), treasury (reserve movements only), guardian (pause; never payable claims) | Each contract grants its own roles; the oracle's, share token's and verifier's admins and the oracle's attester and guardian are constructor parameters independent of the vault's roles, equal to governance, compliance and guardian by deploy only; treasury and guardian distinct, treasury and governance distinct, compliance distinct from governance and treasury; governance and guardian may coincide; the vault's manager role is unconstrained | | **Custodian** | Off-chain party holding the real-world structure; a genesis-configured slot rotatable only by governance | Not an on-chain authority | @@ -588,7 +588,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. | +| 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. | | 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/scripts/harness/deploy.ts b/scripts/harness/deploy.ts index 8b230e0..ed4e059 100644 --- a/scripts/harness/deploy.ts +++ b/scripts/harness/deploy.ts @@ -156,6 +156,13 @@ async function grantAndVerify( throw new Error("the vault did not receive the share token's manager role") console.log("vault holds share_token manager role") + // Grants are instant only during setup, so the token's setup closes after them. + await (await token.bind_vault({ vault, caller })).signAndSend() + await (await token.finish_setup({ caller })).signAndSend() + if ((await token.setup_open()).result) + throw new Error("the share token's setup did not finish") + console.log("share_token bound to the vault, setup finished") + const { Client: IdentityVerifier } = (await import( clientEntry("identity_verifier") )) as typeof IdentityVerifierModule diff --git a/scripts/harness/extend.ts b/scripts/harness/extend.ts index b8e27b7..9550319 100644 --- a/scripts/harness/extend.ts +++ b/scripts/harness/extend.ts @@ -30,8 +30,8 @@ interface DeploymentRecord { } // Contracts whose storage holds a timelock change queue, by the name they -// deploy under. Only the vault keeps one today. -const CONTRACTS_WITH_QUEUES = new Set(["async_vault"]) +// deploy under. +const CONTRACTS_WITH_QUEUES = new Set(["async_vault", "share_token"]) /** * The timelock's own storage key for a change still waiting: diff --git a/scripts/harness/guards.test.ts b/scripts/harness/guards.test.ts index 8106e15..8579ccd 100644 --- a/scripts/harness/guards.test.ts +++ b/scripts/harness/guards.test.ts @@ -201,18 +201,55 @@ describe("the compliance authority's reach", () => { ) }) - it("compliance cannot swap the identity verifier", async () => { + it("compliance cannot propose a new identity verifier", async () => { await expect( send( - shareToken(c, compliance).set_identity_verifier({ - identity_verifier: c.deployment.contracts.compliance, - operator: compliance.publicKey(), + shareToken(c, compliance).propose_change({ + change: { + tag: "IdentityVerifier", + values: [c.deployment.contracts.compliance], + }, + caller: compliance.publicKey(), }), ), ).rejects.toThrow(/signatures/) }) }) +describe("the share token after setup", () => { + const caller = () => governance.publicKey() + const verifier = () => c.deployment.contracts.identity_verifier + + it("refuses an instant verifier swap, even from governance", async () => { + const token = shareToken(c, governance) + expectRefused( + await token.set_identity_verifier({ + identity_verifier: verifier(), + operator: caller(), + }), + token, + "UseTimelock", + ) + }) + + it("queues the swap behind the vault's delay", async () => { + const token = shareToken(c, governance) + const id = await send( + token.propose_change({ + change: { tag: "IdentityVerifier", values: [verifier()] }, + caller: caller(), + }), + ) + expectRefused( + await token.apply_change({ id, caller: caller() }), + token, + "ChangeNotReady", + ) + await send(token.cancel_change({ id, caller: caller() })) + expect((await token.pending_changes()).result).toEqual([]) + }) +}) + describe("invalid amounts and duplicate requests in an epoch", () => { it("rejects zero deposit amount", async () => { const v = vault(c, first)