From 0db2b501808630f702f08d403ffdd73d61dcd561 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:27:43 -0300 Subject: [PATCH 1/2] fix(token): check the contract a swap installs After setup, a Compliance or IdentityVerifier change waited for the vault's timelock, but nothing checked the new contract. Rules that did not list the token, or ran on another vault's clock, landed and stopped every mint, transfer and burn. A verifier that refused the vault or did not answer made holders look delisted. propose_change and apply_change now refuse such a swap. New rules must name the token's vault with their setup closed and list the token in linked_tokens (ComplianceNotBound, 7017). A new verifier must accept the vault's address (VerifierRefusesVault, 7018). The calls go through try_ clients, so a contract that is missing or fails counts as a refusal instead of aborting the call. The vault test that pinned an unbound swap landing now expects the refusal. The design notes, the kit spec, the authorization matrix and the test map list the checks. --- contracts/async-vault/src/test/compliance.rs | 45 ++-- contracts/share-token/src/contract.rs | 5 + contracts/share-token/src/error.rs | 10 + contracts/share-token/src/governance.rs | 3 + contracts/share-token/src/guards.rs | 53 +++- contracts/share-token/src/lib.rs | 7 +- contracts/share-token/src/test_governance.rs | 242 +++++++++++++++++-- contracts/share-token/src/test_guards.rs | 78 ++++++ crates/strata-bindings/src/lib.rs | 7 +- docs/AUTHORIZATION_MATRIX.md | 4 +- docs/TESTING.md | 1 + docs/kit-spec.md | 14 +- docs/strata-product-and-architecture.md | 6 +- 13 files changed, 411 insertions(+), 64 deletions(-) diff --git a/contracts/async-vault/src/test/compliance.rs b/contracts/async-vault/src/test/compliance.rs index d7bb16e6..821c0577 100644 --- a/contracts/async-vault/src/test/compliance.rs +++ b/contracts/async-vault/src/test/compliance.rs @@ -1,5 +1,5 @@ use soroban_sdk::{contract, contractimpl}; -use stellar_tokens::rwa::compliance::{ComplianceError, ComplianceHook}; +use stellar_tokens::rwa::{compliance::ComplianceHook, RWA}; use super::*; @@ -114,7 +114,7 @@ fn a_compliance_module_that_refuses_the_mint_blocks_a_priced_claim() { } #[test] -fn an_unbound_compliance_swap_lands_and_stops_every_move() { +fn an_unbound_compliance_swap_is_refused_and_every_move_goes_on() { let f = setup(); let holder = f.holder(10); let investor = f.investor(1_000); @@ -122,28 +122,20 @@ fn an_unbound_compliance_swap_lands_and_stops_every_move() { f.vault.request_redeem(&holder, &4); f.fulfill_epoch(wad(2)); - let unbound = f.e.register(Compliance, (f.admin.clone(),)); - let id = f - .share - .propose_change(&share_token::TokenChange::Compliance(unbound.clone())); - f.advance(MIN_UPGRADE_DELAY); - f.share.apply_change(&id); - assert_eq!(f.share.compliance(), unbound); - - let token_not_bound = Some(Ok(ComplianceError::TokenNotBound.into())); - assert_eq!( - f.share.try_transfer(&holder, &investor, &1).err(), - token_not_bound - ); + let unbound = ComplianceClient::new(&f.e, &f.e.register(Compliance, (f.admin.clone(),))); + unbound.bind_vault(&f.vault.address); + unbound.finish_setup(); assert_eq!( - f.vault.try_claim_deposit(&investor, &epoch).err(), - token_not_bound + f.share + .try_propose_change(&share_token::TokenChange::Compliance(unbound.address)) + .err(), + Some(Ok(share_token::ShareTokenError::ComplianceNotBound.into())) ); - assert_eq!( - f.vault.try_claim_redeem(&holder, &epoch).err(), - token_not_bound - ); - assert!(!f.vault.health().compliance_bound); + + f.share.transfer(&holder, &investor, &1); + assert_eq!(f.vault.claim_deposit(&investor, &epoch), 50); + assert_eq!(f.vault.claim_redeem(&holder, &epoch), 8); + assert!(f.vault.health().compliance_bound); } #[test] @@ -151,12 +143,11 @@ fn a_verifier_that_cannot_answer_counts_as_a_failed_check() { let f = setup(); let investor = f.investor(1_000); + // A swap refuses it; this stands in for a verifier that stops answering later. let silent = Address::generate(&f.e); - let id = f - .share - .propose_change(&share_token::TokenChange::IdentityVerifier(silent.clone())); - f.advance(MIN_UPGRADE_DELAY); - f.share.apply_change(&id); + f.e.as_contract(&f.share.address, || { + RWA::set_identity_verifier(&f.e, &silent) + }); assert_eq!(f.share.identity_verifier(), silent); refused( diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index 054250bf..d952484b 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -126,6 +126,9 @@ impl ShareToken { /// /// # Errors /// + /// * `ComplianceNotBound` - The new compliance contract does not list + /// this token, or is not on the vault's clock with its setup closed. + /// * `VerifierRefusesVault` - The new verifier does not accept the vault. /// * `UpgradeAlreadyPending` - An upgrade already waits. /// * `SetupOpen` - Setup has not finished; change it directly. /// * `WindDownActive` - The vault is winding down. @@ -182,6 +185,8 @@ impl ShareToken { /// notice now. /// * `ChangeNotReady` - The delay, plus any time the vault spent paused /// since, has not run. + /// * `ComplianceNotBound`, `VerifierRefusesVault` - The new contract no + /// longer passes the check `propose_change` made. /// * `AccessControlError::RoleNotHeld` - A `RevokeRole` names an account /// without the role. /// * An `Upgrade` whose Wasm is not on the network fails the call. diff --git a/contracts/share-token/src/error.rs b/contracts/share-token/src/error.rs index 276c38b7..4f15b946 100644 --- a/contracts/share-token/src/error.rs +++ b/contracts/share-token/src/error.rs @@ -54,5 +54,15 @@ strata_bindings::error_enum! { /// once it is accepted, cancelled or expired, so no nominee from setup can /// accept after changes start to wait. AdminTransferPending = 7016, + /// The proposed compliance contract does not name this token's vault, + /// its setup is still open, or its `linked_tokens` leaves out this + /// token. Every mint, transfer and burn would fail, or its rules could + /// change without the vault's delay. + ComplianceNotBound = 7017, + /// The proposed identity verifier refuses the vault's address or does + /// not answer `verify_identity`. The vault needs that check to convert + /// a delisted holder's deposit, and a verifier that cannot answer makes + /// every holder look delisted. + VerifierRefusesVault = 7018, } } diff --git a/contracts/share-token/src/governance.rs b/contracts/share-token/src/governance.rs index 970d073a..7bc065f2 100644 --- a/contracts/share-token/src/governance.rs +++ b/contracts/share-token/src/governance.rs @@ -80,6 +80,7 @@ pub(crate) fn finish_setup(e: &Env) { } pub(crate) fn propose(e: &Env, change: TokenChange) -> u32 { + guards::valid_change(e, &change); guards::not_a_second_upgrade(e, &change); let id = queue::propose::(e, &change).unwrap_or_else(|r| refuse(e, r)); let (_, schedule) = @@ -102,6 +103,8 @@ pub(crate) fn cancel(e: &Env, id: u32) { pub(crate) fn apply(e: &Env, id: u32) { let change: TokenChange = queue::take_ready::(e, id).unwrap_or_else(|r| refuse(e, r)); + // The new contract may have changed since the proposal; this check decides. + guards::valid_change(e, &change); let admin = access_control::get_admin(e) .unwrap_or_else(|| panic_with_error!(e, ShareTokenError::AdminRequired)); match &change { diff --git a/contracts/share-token/src/guards.rs b/contracts/share-token/src/guards.rs index fcd26a7f..b8ae6527 100644 --- a/contracts/share-token/src/guards.rs +++ b/contracts/share-token/src/guards.rs @@ -3,7 +3,10 @@ use stellar_access::{ access_control::{self, AccessControlStorageKey}, role_transfer, }; -use stellar_tokens::rwa::{identity_verification::IdentityVerifierClient, RWA}; +use stellar_tokens::rwa::{ + identity_verification::IdentityVerifierClient, utils::token_binder::TokenBinderClient, RWA, +}; +use strata_bindings::VaultBindingClient; use strata_timelock::{queue, setup}; use crate::error::ShareTokenError; @@ -61,6 +64,54 @@ fn upgrade_pending(e: &Env) -> bool { }) } +/// What a change must meet when it is proposed and again when it applies: +/// rules bound here, or a verifier that accepts the vault. +pub(crate) fn valid_change(e: &Env, change: &TokenChange) { + match change { + TokenChange::Compliance(rules) => compliance_bound_here(e, rules), + TokenChange::IdentityVerifier(verifier) => verifier_accepts_vault(e, verifier), + TokenChange::Upgrade(_) + | TokenChange::GrantRole(..) + | TokenChange::RevokeRole(..) + | TokenChange::Admin(_) => {} + } +} + +/// Rules that leave out this token stop every mint, transfer and burn; rules +/// off the vault's clock could change with no delay. +pub(crate) fn compliance_bound_here(e: &Env, rules: &Address) { + if !on_the_vaults_clock(e, rules) || !lists_this_token(e, rules) { + panic_with_error!(e, ShareTokenError::ComplianceNotBound); + } +} + +pub(crate) fn verifier_accepts_vault(e: &Env, verifier: &Address) { + let accepted = setup::source(e).is_some_and(|vault| { + matches!( + IdentityVerifierClient::new(e, verifier).try_verify_identity(&vault), + Ok(Ok(())) + ) + }); + if !accepted { + panic_with_error!(e, ShareTokenError::VerifierRefusesVault); + } +} + +/// `contract` names this token's vault and its own setup has finished. +fn on_the_vaults_clock(e: &Env, contract: &Address) -> bool { + let binding = VaultBindingClient::new(e, contract); + setup::source(e) + .is_some_and(|vault| matches!(binding.try_vault(), Ok(Ok(Some(named))) if named == vault)) + && matches!(binding.try_setup_open(), Ok(Ok(false))) +} + +fn lists_this_token(e: &Env, rules: &Address) -> bool { + matches!( + TokenBinderClient::new(e, rules).try_linked_tokens(), + Ok(Ok(linked)) if linked.contains(e.current_contract_address()) + ) +} + /// Stands in for `#[only_admin]` where an OpenZeppelin trait fixes the caller. pub(crate) fn caller_is_admin(e: &Env, caller: &Address) { if access_control::get_admin(e).as_ref() != Some(caller) { diff --git a/contracts/share-token/src/lib.rs b/contracts/share-token/src/lib.rs index 567750d3..87c41e92 100644 --- a/contracts/share-token/src/lib.rs +++ b/contracts/share-token/src/lib.rs @@ -19,9 +19,10 @@ //! - **The pause halts trading only.** The admin's transfer pause stops //! `transfer` and `transfer_from`; mints, burns, escrow moves and legal //! transfers go on. The cost: it is no emergency stop for the vault. -//! - **A compliance swap is not checked.** The new contract need not list this -//! token. The cost: if it does not, every mint, transfer and burn fails until -//! it binds the token, and the vault's `health()` shows it. +//! - **A swap must fit the vault.** New rules must list this token and run on +//! the vault's clock with their setup closed; a new verifier must accept the +//! vault. Both are checked when proposed and when applied. The cost: after +//! setup, an external module that reports no vault cannot be swapped in. #![no_std] mod contract; diff --git a/contracts/share-token/src/test_governance.rs b/contracts/share-token/src/test_governance.rs index 4e670937..f44367f4 100644 --- a/contracts/share-token/src/test_governance.rs +++ b/contracts/share-token/src/test_governance.rs @@ -6,7 +6,7 @@ use soroban_sdk::{ storage::Instance as _, Address as _, AuthorizedFunction, AuthorizedInvocation, Events as _, Ledger as _, MockAuth, MockAuthInvoke, }, - Address, BytesN, Env, Event as _, IntoVal, String, Symbol, + vec, Address, BytesN, Env, Event as _, IntoVal, String, Symbol, Vec, }; use stellar_access::role_transfer::RoleTransferError; use strata_storage::INSTANCE_EXTEND_AMOUNT; @@ -16,6 +16,8 @@ use crate::{ ChangeApplied, ChangeProposed, ShareToken, ShareTokenClient, ShareTokenError, TokenChange, ADMIN_ACCEPT_LEDGERS, }; +use compliance::{Compliance, ComplianceClient}; +use identity_verifier::{IdentityVerifier, IdentityVerifierClient}; const TWO_DAYS_IN_LEDGERS: u32 = 2 * 17_280; @@ -46,6 +48,74 @@ impl strata_timelock::ClockSource for ClockStub { } } +fn clock_stub<'a>(e: &Env) -> ClockStubClient<'a> { + 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, + }); + vault +} + +/// Compliance rules whose answers a test sets, and may change after a swap to +/// them is proposed. +#[contract] +struct RulesStub; + +#[contractimpl] +impl RulesStub { + pub fn report(e: Env, vault: Option
, setup_open: bool, linked: Vec
) { + let store = e.storage().instance(); + store.set(&symbol_short!("vault"), &vault); + store.set(&symbol_short!("open"), &setup_open); + store.set(&symbol_short!("linked"), &linked); + } + + /// Every view fails from now on. + pub fn silence(e: Env) { + let store = e.storage().instance(); + store.remove(&symbol_short!("vault")); + store.remove(&symbol_short!("open")); + store.remove(&symbol_short!("linked")); + } + + pub fn linked_tokens(e: Env) -> Vec
{ + e.storage() + .instance() + .get(&symbol_short!("linked")) + .unwrap() + } +} + +#[contractimpl(contracttrait)] +impl strata_bindings::VaultBinding for RulesStub { + fn vault(e: &Env) -> Option
{ + e.storage().instance().get(&symbol_short!("vault")).unwrap() + } + + fn setup_open(e: &Env) -> bool { + e.storage().instance().get(&symbol_short!("open")).unwrap() + } +} + +/// A verifier that accepts every account until a test silences it. +#[contract] +struct VerifierStub; + +#[contractimpl] +impl VerifierStub { + pub fn silence(e: Env) { + e.storage().instance().set(&symbol_short!("silent"), &true); + } + + pub fn verify_identity(e: Env, _account: Address) { + assert!(!e.storage().instance().has(&symbol_short!("silent"))); + } +} + struct Fixture<'a> { e: Env, token: ShareTokenClient<'a>, @@ -77,6 +147,33 @@ impl Fixture<'_> { self.token.propose_change(&change) } + fn rules(&self) -> ComplianceClient<'_> { + ComplianceClient::new(&self.e, &self.e.register(Compliance, (self.admin.clone(),))) + } + + /// Rules the token may swap to: they list the token and run on this + /// vault's clock with their setup finished. + fn rules_bound_here(&self) -> Address { + let rules = self.rules(); + rules.bind_token(&self.token.address, &self.admin); + rules.bind_vault(&self.vault.address); + rules.finish_setup(); + rules.address + } + + fn verifier(&self) -> IdentityVerifierClient<'_> { + IdentityVerifierClient::new( + &self.e, + &self.e.register(IdentityVerifier, (self.admin.clone(),)), + ) + } + + fn verifier_accepting_the_vault(&self) -> Address { + let verifier = self.verifier(); + verifier.allow(&self.vault.address, &true); + verifier.address + } + /// 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())); @@ -156,14 +253,7 @@ fn setup<'a>() -> Fixture<'a> { 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, - }); + let vault = clock_stub(&e); Fixture { token: ShareTokenClient::new(&e, &token), vault, @@ -435,7 +525,7 @@ fn a_revoke_lands_through_the_timelock() { 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 id = f.propose(TokenChange::GrantRole(manager(), Address::generate(&f.e))); let eta = f.token.pending_changes().get(0).unwrap().eta; f.advance(DAY_DURATION); @@ -459,13 +549,13 @@ fn a_vault_pause_moves_the_eta_and_holds_the_change() { 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))); + let id = f.propose(TokenChange::GrantRole(manager(), 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))), + .try_propose_change(&TokenChange::GrantRole(manager(), Address::generate(&f.e))), ShareTokenError::WindDownActive, ); refused( @@ -479,7 +569,7 @@ fn nothing_is_proposed_or_applied_while_the_vault_winds_down() { 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))); + let id = f.propose(TokenChange::GrantRole(manager(), Address::generate(&f.e))); f.clock(|c| { c.delay = 30 * DAY_DURATION; c.exit_window = 20 * DAY_DURATION; @@ -510,7 +600,7 @@ 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 change = TokenChange::GrantRole(manager(), Address::generate(&f.e)); let id = f.propose(change.clone()); f.advance(MIN_DELAY); @@ -579,8 +669,8 @@ fn the_hand_over_offer_lasts_seven_days_of_ledgers() { 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 rules = f.rules_bound_here(); + let verifier = f.verifier_accepting_the_vault(); let first = f.propose(TokenChange::Compliance(rules.clone())); let second = f.propose(TokenChange::IdentityVerifier(verifier.clone())); f.advance(MIN_DELAY); @@ -592,11 +682,117 @@ fn applied_rule_and_verifier_changes_take_effect() { assert_eq!(f.token.identity_verifier(), verifier); } +#[test] +fn rules_off_this_vaults_clock_or_without_the_token_are_never_proposed() { + let f = setup(); + f.finish(); + let unlisted = f.rules(); + unlisted.bind_vault(&f.vault.address); + unlisted.finish_setup(); + let elsewhere = f.rules(); + elsewhere.bind_token(&f.token.address, &f.admin); + elsewhere.bind_vault(&clock_stub(&f.e).address); + elsewhere.finish_setup(); + let in_setup = f.rules(); + in_setup.bind_token(&f.token.address, &f.admin); + in_setup.bind_vault(&f.vault.address); + let not_a_contract = Address::generate(&f.e); + + for rules in [ + unlisted.address, + elsewhere.address, + in_setup.address, + not_a_contract, + ] { + refused( + f.token.try_propose_change(&TokenChange::Compliance(rules)), + ShareTokenError::ComplianceNotBound, + ); + } + assert_eq!(f.token.pending_changes().len(), 0); +} + +/// Proposes a swap to rules that qualify, lets `turn` change their answers, +/// then checks the swap no longer lands. +fn refused_once_the_rules_turn(f: &Fixture, turn: impl FnOnce(&RulesStubClient)) { + let rules = RulesStubClient::new(&f.e, &f.e.register(RulesStub, ())); + rules.report( + &Some(f.vault.address.clone()), + &false, + &vec![&f.e, f.token.address.clone()], + ); + let id = f.propose(TokenChange::Compliance(rules.address.clone())); + turn(&rules); + f.advance(MIN_DELAY); + + refused( + f.token.try_apply_change(&id), + ShareTokenError::ComplianceNotBound, + ); + assert_ne!(f.token.compliance(), rules.address); +} + +#[test] +fn rules_that_stop_qualifying_are_never_installed() { + let f = setup(); + f.finish(); + let here = Some(f.vault.address.clone()); + let listed = vec![&f.e, f.token.address.clone()]; + + refused_once_the_rules_turn(&f, |rules| rules.report(&here, &false, &vec![&f.e])); + refused_once_the_rules_turn(&f, |rules| { + rules.report(&Some(clock_stub(&f.e).address), &false, &listed) + }); + refused_once_the_rules_turn(&f, |rules| rules.report(&here, &true, &listed)); + refused_once_the_rules_turn(&f, |rules| rules.silence()); +} + +#[test] +fn a_verifier_that_refuses_the_vault_or_cannot_answer_is_never_proposed() { + let f = setup(); + f.finish(); + let refusing = f.verifier().address; + let not_a_verifier = f.vault.address.clone(); + let not_a_contract = Address::generate(&f.e); + + for verifier in [refusing, not_a_verifier, not_a_contract] { + refused( + f.token + .try_propose_change(&TokenChange::IdentityVerifier(verifier)), + ShareTokenError::VerifierRefusesVault, + ); + } + assert_eq!(f.token.pending_changes().len(), 0); +} + +#[test] +fn a_verifier_that_stops_accepting_the_vault_is_never_installed() { + let f = setup(); + f.finish(); + let delisting = f.verifier(); + delisting.allow(&f.vault.address, &true); + let silenced = VerifierStubClient::new(&f.e, &f.e.register(VerifierStub, ())); + let first = f.propose(TokenChange::IdentityVerifier(delisting.address.clone())); + let second = f.propose(TokenChange::IdentityVerifier(silenced.address.clone())); + + delisting.allow(&f.vault.address, &false); + silenced.silence(); + f.advance(MIN_DELAY); + + for id in [first, second] { + refused( + f.token.try_apply_change(&id), + ShareTokenError::VerifierRefusesVault, + ); + } + assert_eq!(f.token.pending_changes().len(), 2); +} + #[test] fn proposing_and_applying_publish_the_change() { let f = setup(); f.finish(); - let change = TokenChange::Compliance(Address::generate(&f.e)); + let change = TokenChange::GrantRole(manager(), Address::generate(&f.e)); let eta = f.e.ledger().timestamp() + MIN_DELAY; let id = f.propose(change.clone()); @@ -625,7 +821,7 @@ fn proposing_reads_the_vault_clock_once() { f.finish(); let before = f.vault.reads(); - f.propose(TokenChange::Compliance(Address::generate(&f.e))); + f.propose(TokenChange::GrantRole(manager(), Address::generate(&f.e))); assert_eq!(f.vault.reads(), before + 1); } @@ -635,7 +831,7 @@ 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 change = TokenChange::GrantRole(manager(), Address::generate(&f.e)); let eta = f.e.ledger().timestamp() + MIN_DELAY; let id = f.propose(change.clone()); @@ -670,12 +866,12 @@ fn every_admin_call_restores_the_instance_ttl() { f.restores_the_instance(|| f.token.bind_vault(&f.vault.address)); f.restores_the_instance(|| f.token.finish_setup()); - let rules = TokenChange::Compliance(Address::generate(&f.e)); + let grant = TokenChange::GrantRole(manager(), Address::generate(&f.e)); let mut id = 0; - f.restores_the_instance(|| id = f.propose(rules.clone())); + f.restores_the_instance(|| id = f.propose(grant.clone())); f.restores_the_instance(|| f.token.cancel_change(&id)); - let id = f.propose(rules); + let id = f.propose(grant); f.advance(MIN_DELAY); f.restores_the_instance(|| f.token.apply_change(&id)); } @@ -738,7 +934,7 @@ fn each_admin_call_asks_the_admin_for_one_signature() { f.token.finish_setup(); f.signed_once_by(&f.admin, "finish_setup", soroban_sdk::Vec::new(&f.e)); - let change = TokenChange::Compliance(rules); + let change = TokenChange::Compliance(f.rules_bound_here()); let id = f.token.propose_change(&change); f.signed_once_by(&f.admin, "propose_change", (change.clone(),).into_val(&f.e)); f.token.cancel_change(&id); diff --git a/contracts/share-token/src/test_guards.rs b/contracts/share-token/src/test_guards.rs index fa7a6208..acb2a635 100644 --- a/contracts/share-token/src/test_guards.rs +++ b/contracts/share-token/src/test_guards.rs @@ -12,6 +12,7 @@ use strata_timelock::{Clock, MIN_DELAY}; use crate::guards; use crate::{ShareToken, ShareTokenClient, ShareTokenError, TokenChange, MAX_REFERENCE_LEN}; +use compliance::{Compliance, ComplianceClient}; use identity_verifier::{IdentityVerifier, IdentityVerifierClient}; /// Plays the vault: the token binds only to an address that reports a clock. @@ -45,6 +46,18 @@ impl Fixture<'_> { self.token.bind_vault(&self.vault); self.token.finish_setup(); } + + /// Rules on the vault's clock with their setup finished. + fn rules_listing(&self, tokens: &[&Address]) -> Address { + let rules = + ComplianceClient::new(&self.e, &self.e.register(Compliance, (self.admin.clone(),))); + for token in tokens { + rules.bind_token(token, &self.admin); + } + rules.bind_vault(&self.vault); + rules.finish_setup(); + rules.address + } } fn setup<'a>() -> Fixture<'a> { @@ -133,6 +146,71 @@ fn not_a_second_upgrade_refuses_an_upgrade_while_one_waits() { holds(&f, || guards::not_a_second_upgrade(&f.e, &grant)); } +#[test] +fn compliance_bound_here_refuses_rules_without_the_token_or_before_the_vault_is_known() { + let f = setup(); + let listing = f.rules_listing(&[&f.token.address]); + refuses(&f, ShareTokenError::ComplianceNotBound, || { + guards::compliance_bound_here(&f.e, &listing) + }); + + f.finish_setup(); + holds(&f, || guards::compliance_bound_here(&f.e, &listing)); + for rules in [f.rules_listing(&[]), Address::generate(&f.e)] { + refuses(&f, ShareTokenError::ComplianceNotBound, || { + guards::compliance_bound_here(&f.e, &rules) + }); + } +} + +#[test] +fn verifier_accepts_vault_refuses_a_verifier_that_refuses_or_cannot_answer() { + let f = setup(); + f.identity.allow(&f.vault, &true); + refuses(&f, ShareTokenError::VerifierRefusesVault, || { + guards::verifier_accepts_vault(&f.e, &f.identity.address) + }); + + f.finish_setup(); + holds(&f, || { + guards::verifier_accepts_vault(&f.e, &f.identity.address) + }); + for verifier in [f.vault.clone(), Address::generate(&f.e)] { + refuses(&f, ShareTokenError::VerifierRefusesVault, || { + guards::verifier_accepts_vault(&f.e, &verifier) + }); + } + f.identity.allow(&f.vault, &false); + refuses(&f, ShareTokenError::VerifierRefusesVault, || { + guards::verifier_accepts_vault(&f.e, &f.identity.address) + }); +} + +#[test] +fn valid_change_checks_only_rule_and_verifier_swaps() { + let f = setup(); + f.finish_setup(); + f.identity.allow(&f.vault, &true); + let stranger = Address::generate(&f.e); + for change in [ + TokenChange::Upgrade(BytesN::from_array(&f.e, &[1; 32])), + TokenChange::GrantRole(symbol_short!("manager"), stranger.clone()), + TokenChange::RevokeRole(symbol_short!("manager"), stranger.clone()), + TokenChange::Admin(stranger.clone()), + TokenChange::Compliance(f.rules_listing(&[&f.token.address])), + TokenChange::IdentityVerifier(f.identity.address.clone()), + ] { + holds(&f, || guards::valid_change(&f.e, &change)); + } + + refuses(&f, ShareTokenError::ComplianceNotBound, || { + guards::valid_change(&f.e, &TokenChange::Compliance(stranger.clone())) + }); + refuses(&f, ShareTokenError::VerifierRefusesVault, || { + guards::valid_change(&f.e, &TokenChange::IdentityVerifier(stranger.clone())) + }); +} + #[test] fn caller_is_admin_refuses_anyone_but_the_admin() { let f = setup(); diff --git a/crates/strata-bindings/src/lib.rs b/crates/strata-bindings/src/lib.rs index a7d70730..d2feb01d 100644 --- a/crates/strata-bindings/src/lib.rs +++ b/crates/strata-bindings/src/lib.rs @@ -5,9 +5,10 @@ //! - **The compiler checks the feed.** The vault prices from `OracleFeed`, //! which the kit's oracle and any adopter's feed implement. The cost: a feed //! must answer its four calls as written, or use an adapter. -//! - **Bound contracts report their binding.** The vault's `finish_setup` reads -//! `VaultBinding`. The cost: a compliance contract that does not answer it is -//! accepted, on trust, as external. +//! - **Bound contracts report their binding.** The vault's `finish_setup` and +//! the token's compliance swaps read `VaultBinding`. The cost: setup accepts +//! a compliance contract that does not answer it, on trust, as external; a +//! later swap refuses one. //! - **Fixed blocks of error codes.** The vault, token, oracle and compliance //! contract each own one; `error_enum!` fails the build outside it, //! `errors.rs` on overlaps. The cost: removed codes stay retired. diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index 05721e18..af099a7f 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -113,7 +113,7 @@ Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs. ### 5.2 ShareToken (`contracts/share-token`) -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. The token's `pause` refuses SEP-41 transfers between holders only. The vault's own escrow moves (`forced_transfer`), mint and burn ignore it, so a token pause stops neither entry nor exit. +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. A compliance or verifier swap is checked when proposed and again when applied: new rules must run on the vault's clock and list the token, and a new verifier must accept the vault. Revoking `compliance` and `pause`/`unpause` stay instant. The `manager` role is held by the vault and the `compliance` role by the compliance authority. The token's `pause` refuses SEP-41 transfers between holders only. The vault's own escrow moves (`forced_transfer`), mint and burn ignore it, so a token pause stops neither entry nor exit. 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). What each privileged call asks to sign: `test_governance::each_admin_call_asks_the_admin_for_one_signature`, `test::each_role_call_asks_its_holder_for_one_signature` and `test::legal_transfer::a_legal_transfer_or_a_recovery_asks_each_signer_once`. @@ -137,6 +137,8 @@ Tests: [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.r | `propose_change` | `#[only_admin]` | 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]` | The change must be pending | `test_governance::only_the_admin_proposes_cancels_and_applies` | | `apply_change` | `#[only_admin]` | 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` | +| `propose_change` (`Compliance(address)`, `IdentityVerifier(address)`) | `#[only_admin]` | New rules name the token's vault through `vault()` with their setup closed and list the token in `linked_tokens` (`ComplianceNotBound`); a new verifier's `verify_identity` accepts the vault's address (`VerifierRefusesVault`). A contract that does not answer fails | `test_governance::rules_off_this_vaults_clock_or_without_the_token_are_never_proposed`, `test_governance::a_verifier_that_refuses_the_vault_or_cannot_answer_is_never_proposed`, `test::compliance::an_unbound_compliance_swap_is_refused_and_every_move_goes_on` in `contracts/async-vault` | +| `apply_change` (`Compliance(address)`, `IdentityVerifier(address)`) | `#[only_admin]` | Delay run; the new rules or verifier still pass the same check | `test_governance::applied_rule_and_verifier_changes_take_effect`, `test_governance::rules_that_stop_qualifying_are_never_installed`, `test_governance::a_verifier_that_stops_accepting_the_vault_is_never_installed` | | `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`) | diff --git a/docs/TESTING.md b/docs/TESTING.md index a3dfe540..f06c21e3 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -143,6 +143,7 @@ fixture wires them as the deploy does and finishes the vault's setup; | 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`, the token has no shares outstanding, it passes the identity check, its address is not frozen, the token is bound to compliance (which runs on this vault's clock with its setup closed unless it is an external module that does not answer `VaultBinding`), the asset has the token's decimals, the asset, token, oracle and vault have four different addresses, the oracle answers as a feed, and a custodian other than the vault is set. `health()` reports each check, and before setup finishes the vault takes no deposit | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs), [`contracts/async-vault/src/test/health.rs`](../contracts/async-vault/src/test/health.rs) | `every_wiring_step_is_checked_before_setup_finishes`, `each_missing_wiring_step_shows_in_its_own_flag`, `a_token_that_cannot_answer_fails_its_checks_instead_of_trapping`, `a_compliance_contract_from_outside_the_kit_is_accepted`, `a_deposit_before_setup_finishes_is_refused`, `setup_cannot_finish_while_a_hand_over_is_pending` | | The vault is never its own custodian: the direct setter, during setup and after, and a `Custodian` change, when proposed and when applied, refuse it with `InvalidCustodian` | [`contracts/async-vault/src/test/treasury.rs`](../contracts/async-vault/src/test/treasury.rs), [`contracts/async-vault/src/test/governance.rs`](../contracts/async-vault/src/test/governance.rs), [`contracts/async-vault/src/test/guards.rs`](../contracts/async-vault/src/test/guards.rs) | `setting_the_vault_as_its_own_custodian_is_refused`, `a_custodian_change_to_the_vault_is_refused_when_proposed_and_when_applied`, `custodian_not_the_vault_refuses_the_vault_itself` | +| The share token swaps to new compliance rules only if they run on the vault's clock with their setup closed and list the token (`ComplianceNotBound`), and to a new verifier only if it accepts the vault (`VerifierRefusesVault`), checked when proposed and again when applied; a contract that does not answer is refused | [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs), [`contracts/share-token/src/test_guards.rs`](../contracts/share-token/src/test_guards.rs), [`contracts/async-vault/src/test/compliance.rs`](../contracts/async-vault/src/test/compliance.rs) | `rules_off_this_vaults_clock_or_without_the_token_are_never_proposed`, `rules_that_stop_qualifying_are_never_installed`, `a_verifier_that_refuses_the_vault_or_cannot_answer_is_never_proposed`, `a_verifier_that_stops_accepting_the_vault_is_never_installed`, `applied_rule_and_verifier_changes_take_effect`, `an_unbound_compliance_swap_is_refused_and_every_move_goes_on` | | 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 69c78219..bb30b0d8 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -95,6 +95,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - 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. + - A compliance swap needs a contract that names the token's vault through `vault()` with its own setup finished, and lists the token in `linked_tokens` (`ComplianceNotBound`). A verifier swap needs a verifier whose `verify_identity` accepts the vault's address (`VerifierRefusesVault`). Both are checked when the change is proposed and again when it applies, and a contract that does not answer fails the check. - Once its one-time setup closes, the oracle's upgrades, grants, admin hand-overs and every config change except a shorter `freshness_duration` wait on the vault's clock under the same rules; a direct call is refused with `UseTimelock`. Revoking `attester` or `guardian`, the NAV pause and `clear_latest` stay instant. - Once its one-time setup closes, the compliance contract's module changes, token bindings and admin hand-overs wait on the vault's clock under the same rules; a direct call is refused with `UseTimelock`. @@ -295,9 +296,9 @@ Sealing is meant to become permissionless once a minimum epoch duration bounds i | **`ShareToken`** | `set_compliance`, `set_identity_verifier` | Governance | `operator` must be the admin (`AdminRequired`), then `operator.require_auth()`; 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`** | `propose_change` | Governance | `#[only_admin]`; after setup; a compliance or verifier swap passes its check (`ComplianceNotBound`, `VerifierRefusesVault`) | 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`** | `apply_change` | Governance | `#[only_admin]`; after the vault's delay; a compliance or verifier swap passes its check again | 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 | @@ -529,6 +530,13 @@ compliance contract (OpenZeppelin `TokenBinder`), and calls the oracle's `state` and `freshness_duration` (`OracleFeed`). A replacement for any of these must answer those calls, or the vault's setup cannot finish. +After setup, the share token makes the same kind of check before it swaps a +contract, when the swap is proposed and again when it applies. New compliance +rules must answer `VaultBinding` with the token's vault and their setup closed, +and list the token in `linked_tokens`; unlike `finish_setup`, the token accepts +no external module that does not answer. A new identity verifier must accept +the vault's address in `verify_identity`. + --- ## 7. Error Codes and Entrypoint Docs @@ -544,7 +552,7 @@ for the same failure, so `304` says what failed, not where. |---|---|---|---| | `nav_oracle` | `OracleError` | 3000–3999 | 3001–3021, 3025–3026 | | `async_vault` | `VaultError` | 6000–6999 | 6000–6001, 6005, 6007, 6009, 6012, 6014, 6029–6035, 6037–6075 | -| `share_token` | `ShareTokenError` | 7000–7999 | 7000–7013, 7015–7016 | +| `share_token` | `ShareTokenError` | 7000–7999 | 7000–7013, 7015–7018 | | `compliance` | `ComplianceContractError` | 8000–8999 | 8001–8011 | The blocks are defined once, in `crates/strata-bindings` (`ORACLE_ERRORS`, diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index 5866d20e..bc69d8bb 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -287,9 +287,9 @@ number. | 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. The share token and the oracle upgrade 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 (set only by the admin; governance by deploy) | Manager role held only by the vault, for mint, burn and escrow; compliance role held by the compliance authority, for freezes, plus legal transfers and recovery co-signed by the token admin; swapping the compliance or identity-verifier contract needs the admin and, after a one-time setup, waits for the vault's timelock, as do the token's upgrades, role grants and admin hand-overs | +| **Share token** | OZ SEP-41 + SEP-57 RWA extensions: freeze, forced transfer, recovery, identity and compliance checks on every transfer, independent transfer pause (set only by the admin; governance by deploy) | Manager role held only by the vault, for mint, burn and escrow; compliance role held by the compliance authority, for freezes, plus legal transfers and recovery co-signed by the token admin; swapping the compliance or identity-verifier contract needs the admin and, after a one-time setup, waits for the vault's timelock, as do the token's upgrades, role grants and admin hand-overs. New rules must run on the vault's clock and list the token, and a new verifier must accept the vault, both when the swap is proposed and when it applies | | **Token roles** | Access-control roles on the share token, checked directly via native access control: `manager` mints, burns and escrows; `compliance` freezes, and makes legal transfers and recoveries co-signed by the token admin | 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 | The compliance authority writes the allowlist on the identity verifier. Governance runs the compliance contract as its admin: adding or removing a module and binding or unbinding a token are instant only during its one-time setup, and after it wait for the vault's timelock, cannot land while the vault is paused and never land during a wind-down. Replaceable by OZ's identity verifier and compliance contracts (with RWA Wizard modules); after setup, the token's swap to them waits for the vault's timelock | +| **Compliance module** | Implements the SEP-57 identity and rules interfaces the share token consults, with the allowlist as its only rule | The compliance authority writes the allowlist on the identity verifier. Governance runs the compliance contract as its admin: adding or removing a module and binding or unbinding a token are instant only during its one-time setup, and after it wait for the vault's timelock, cannot land while the vault is paused and never land during a wind-down. Replaceable by OZ's identity verifier and compliance contracts (with RWA Wizard modules). After setup, the token's swap waits for the vault's timelock, and new rules must report this vault through `VaultBinding` with their setup closed, which OZ's compliance contract does not do on its own | | **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 | @@ -626,7 +626,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. A compromised attester is replaced in this order: pause the NAV, revoke the attester (instant), grant a replacement (through the timelock after setup, so the NAV stays paused that long), clear the stored report, which would otherwise price epochs again once the pause lifts, then lift the pause ([`kit-spec.md`](./kit-spec.md) §3) | -| Governance keys | Malicious upgrade | Timelock on every code change and on the delay itself: at least seven days, never shorter than the standing notice, at most 90 days. The delay is an announcement period. A pause freezes the timelock clock and pushes the ETA back by the time paused, so the whole delay runs unpaused, and applying while paused is refused. The delay does not guarantee an exit before a change lands: an exit also needs a sealed epoch, a valid price accepted after the seal, and cash in the reserve to pay the claim. 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, its address is not frozen on the token, the token is bound to its compliance contract, the asset has the token's decimals, the asset, the token, the oracle and the vault are four different addresses, the oracle answers the feed interface, and a custodian other than the vault is set; the kit's own compliance contract must also run on the vault's clock with its setup closed. 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. On the kit's compliance contract, adding or removing a module, binding or unbinding a token and handing over the admin wait on the same clock once its setup closes. | +| Governance keys | Malicious upgrade | Timelock on every code change and on the delay itself: at least seven days, never shorter than the standing notice, at most 90 days. The delay is an announcement period. A pause freezes the timelock clock and pushes the ETA back by the time paused, so the whole delay runs unpaused, and applying while paused is refused. The delay does not guarantee an exit before a change lands: an exit also needs a sealed epoch, a valid price accepted after the seal, and cash in the reserve to pay the claim. 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, its address is not frozen on the token, the token is bound to its compliance contract, the asset has the token's decimals, the asset, the token, the oracle and the vault are four different addresses, the oracle answers the feed interface, and a custodian other than the vault is set; the kit's own compliance contract must also run on the vault's clock with its setup closed. The share token's upgrades, grants, `manager` revocations, admin handovers and compliance or verifier swaps wait on the same clock once its setup closes; new rules must also run on that clock and list the token, and a new verifier must accept the vault. 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. On the kit's compliance contract, adding or removing a module, binding or unbinding a token and handing over the admin wait on the same clock once its setup closes. | | Mis-wired deployment | A vault goes live without every binding wired | `health()` reports each wiring step; `finish_setup` refuses until all hold, and the vault takes no deposit before that | | 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 | From 740230409c6a59954bbd23dfbc53d9694aaa1e1b Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:04:26 -0300 Subject: [PATCH 2/2] test(harness): propose a valid verifier in the signature guard The guard that checks the compliance account cannot propose a token change named the compliance contract as the new identity verifier. The token now refuses a verifier that does not accept the vault, so the call failed in simulation instead of on the missing signature. Propose the deployed verifier, so the signature is the only reason left. --- scripts/harness/guards.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/harness/guards.test.ts b/scripts/harness/guards.test.ts index f5d283da..02789b82 100644 --- a/scripts/harness/guards.test.ts +++ b/scripts/harness/guards.test.ts @@ -207,7 +207,7 @@ describe("the compliance authority's reach", () => { shareToken(c, compliance).propose_change({ change: { tag: "IdentityVerifier", - values: [c.deployment.contracts.compliance], + values: [c.deployment.contracts.identity_verifier], }, }), ),