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/5] 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/5] 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], }, }), ), From a8fcf8717786acb2a34dc90b0fb3d3c90b37fe73 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:29:48 -0300 Subject: [PATCH 3/5] fix(token): accept external rules in a swap After setup, a compliance swap refused any contract that does not answer VaultBinding, though the vault's finish_setup accepts such an external module. A standard compliance contract, such as OpenZeppelin's, could never replace the testnet stub. The swap check now follows the rule finish_setup uses. The new contract must list the token in linked_tokens. If it answers vault() and setup_open(), they must name the token's vault with setup closed. If either view does not answer, the contract is external and accepted on trust. The check still runs when the change is proposed and again when it applies, and ComplianceNotBound (7017) is still the error. The design notes, the error and entrypoint docs, the kit spec, the design doc, the authorization matrix and the test map now say so. --- contracts/share-token/src/contract.rs | 2 +- contracts/share-token/src/error.rs | 9 +- contracts/share-token/src/guards.rs | 15 ++-- contracts/share-token/src/lib.rs | 9 +- contracts/share-token/src/test_governance.rs | 91 ++++++++++++++++++-- crates/strata-bindings/src/lib.rs | 5 +- docs/AUTHORIZATION_MATRIX.md | 6 +- docs/TESTING.md | 2 +- docs/kit-spec.md | 14 +-- docs/strata-product-and-architecture.md | 6 +- 10 files changed, 122 insertions(+), 37 deletions(-) diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index d952484b..f743e051 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -127,7 +127,7 @@ 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. + /// this token, or reports another vault or an open setup. /// * `VerifierRefusesVault` - The new verifier does not accept the vault. /// * `UpgradeAlreadyPending` - An upgrade already waits. /// * `SetupOpen` - Setup has not finished; change it directly. diff --git a/contracts/share-token/src/error.rs b/contracts/share-token/src/error.rs index 4f15b946..eb84b777 100644 --- a/contracts/share-token/src/error.rs +++ b/contracts/share-token/src/error.rs @@ -54,10 +54,11 @@ 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. + /// The proposed compliance contract's `linked_tokens` leaves out this + /// token, so every mint, transfer and burn would fail. Or it answers + /// `vault()` and `setup_open()` with another vault or an open setup, so + /// its rules could change without the vault's delay. One that does not + /// answer them is an external module and passes, on trust. ComplianceNotBound = 7017, /// The proposed identity verifier refuses the vault's address or does /// not answer `verify_identity`. The vault needs that check to convert diff --git a/contracts/share-token/src/guards.rs b/contracts/share-token/src/guards.rs index b8ae6527..0a42d542 100644 --- a/contracts/share-token/src/guards.rs +++ b/contracts/share-token/src/guards.rs @@ -78,7 +78,7 @@ pub(crate) fn valid_change(e: &Env, change: &TokenChange) { } /// Rules that leave out this token stop every mint, transfer and burn; rules -/// off the vault's clock could change with no delay. +/// that report another vault or an open setup 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); @@ -97,12 +97,17 @@ pub(crate) fn verifier_accepts_vault(e: &Env, verifier: &Address) { } } -/// `contract` names this token's vault and its own setup has finished. +/// When `contract` answers both binding views, they name this token's vault +/// with its setup closed. 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))) + match (binding.try_vault(), binding.try_setup_open()) { + (Ok(Ok(named)), Ok(Ok(open))) => { + setup::source(e).is_some_and(|vault| named == Some(vault)) && !open + } + // An external module answers no binding view; its governance is a trust assumption. + _ => true, + } } fn lists_this_token(e: &Env, rules: &Address) -> bool { diff --git a/contracts/share-token/src/lib.rs b/contracts/share-token/src/lib.rs index 87c41e92..3760583c 100644 --- a/contracts/share-token/src/lib.rs +++ b/contracts/share-token/src/lib.rs @@ -19,10 +19,11 @@ //! - **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 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. +//! - **A swap must fit the vault.** New rules must list this token, and rules +//! that answer `VaultBinding` must 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: as at setup, an external module that +//! does not answer is taken on trust, and its rules can change with no delay. #![no_std] mod contract; diff --git a/contracts/share-token/src/test_governance.rs b/contracts/share-token/src/test_governance.rs index f44367f4..53c26090 100644 --- a/contracts/share-token/src/test_governance.rs +++ b/contracts/share-token/src/test_governance.rs @@ -82,6 +82,11 @@ impl RulesStub { store.remove(&symbol_short!("linked")); } + /// One view, `vault` or `open`, fails from now on. + pub fn forget(e: Env, view: Symbol) { + e.storage().instance().remove(&view); + } + pub fn linked_tokens(e: Env) -> Vec
{ e.storage() .instance() @@ -101,6 +106,27 @@ impl strata_bindings::VaultBinding for RulesStub { } } +/// Compliance rules from outside the kit, such as OpenZeppelin's: they list +/// the tokens a test sets and answer no binding view. +#[contract] +struct ExternalRules; + +#[contractimpl] +impl ExternalRules { + pub fn list(e: Env, linked: Vec
) { + e.storage() + .instance() + .set(&symbol_short!("linked"), &linked); + } + + pub fn linked_tokens(e: Env) -> Vec
{ + e.storage() + .instance() + .get(&symbol_short!("linked")) + .unwrap() + } +} + /// A verifier that accepts every account until a test silences it. #[contract] struct VerifierStub; @@ -161,6 +187,23 @@ impl Fixture<'_> { rules.address } + /// Stub rules that qualify until a test changes their answers. + fn rules_stub_bound_here(&self) -> RulesStubClient<'_> { + let rules = RulesStubClient::new(&self.e, &self.e.register(RulesStub, ())); + rules.report( + &Some(self.vault.address.clone()), + &false, + &vec![&self.e, self.token.address.clone()], + ); + rules + } + + fn rules_from_outside(&self, linked: &Vec
) -> ExternalRulesClient<'_> { + let rules = ExternalRulesClient::new(&self.e, &self.e.register(ExternalRules, ())); + rules.list(linked); + rules + } + fn verifier(&self) -> IdentityVerifierClient<'_> { IdentityVerifierClient::new( &self.e, @@ -696,12 +739,14 @@ fn rules_off_this_vaults_clock_or_without_the_token_are_never_proposed() { let in_setup = f.rules(); in_setup.bind_token(&f.token.address, &f.admin); in_setup.bind_vault(&f.vault.address); + let outside_for_another_token = f.rules_from_outside(&vec![&f.e, Address::generate(&f.e)]); let not_a_contract = Address::generate(&f.e); for rules in [ unlisted.address, elsewhere.address, in_setup.address, + outside_for_another_token.address, not_a_contract, ] { refused( @@ -715,12 +760,7 @@ fn rules_off_this_vaults_clock_or_without_the_token_are_never_proposed() { /// 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 rules = f.rules_stub_bound_here(); let id = f.propose(TokenChange::Compliance(rules.address.clone())); turn(&rules); f.advance(MIN_DELAY); @@ -747,6 +787,45 @@ fn rules_that_stop_qualifying_are_never_installed() { refused_once_the_rules_turn(&f, |rules| rules.silence()); } +/// Their own governance is a trust assumption, as at the vault's setup. +#[test] +fn rules_from_outside_the_kit_are_installed_while_they_list_the_token() { + let f = setup(); + f.finish(); + let listed = vec![&f.e, f.token.address.clone()]; + let kept = f.rules_from_outside(&listed); + let dropped = f.rules_from_outside(&listed); + let first = f.propose(TokenChange::Compliance(kept.address.clone())); + let second = f.propose(TokenChange::Compliance(dropped.address.clone())); + dropped.list(&vec![&f.e]); + f.advance(MIN_DELAY); + + refused( + f.token.try_apply_change(&second), + ShareTokenError::ComplianceNotBound, + ); + f.token.apply_change(&first); + assert_eq!(f.token.compliance(), kept.address); +} + +/// As at the vault's setup, a contract that fails either binding view counts +/// as external, so only its `linked_tokens` decides. +#[test] +fn rules_that_stop_answering_a_binding_view_are_installed_as_external() { + let f = setup(); + f.finish(); + + for view in [symbol_short!("vault"), symbol_short!("open")] { + let rules = f.rules_stub_bound_here(); + let id = f.propose(TokenChange::Compliance(rules.address.clone())); + rules.forget(&view); + f.advance(MIN_DELAY); + + f.token.apply_change(&id); + assert_eq!(f.token.compliance(), rules.address); + } +} + #[test] fn a_verifier_that_refuses_the_vault_or_cannot_answer_is_never_proposed() { let f = setup(); diff --git a/crates/strata-bindings/src/lib.rs b/crates/strata-bindings/src/lib.rs index d2feb01d..1226f12d 100644 --- a/crates/strata-bindings/src/lib.rs +++ b/crates/strata-bindings/src/lib.rs @@ -6,9 +6,8 @@ //! 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` 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. +//! the token's compliance swaps read `VaultBinding`. The cost: both accept a +//! compliance contract that does not answer it, on trust, as external. //! - **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 af099a7f..293ced98 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. 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. +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 list the token, and rules that answer `vault()` and `setup_open()` must run on the vault's clock with their setup closed. As at `finish_setup`, rules that do not answer them count as an external module and are accepted. 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,8 +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` | +| `propose_change` (`Compliance(address)`, `IdentityVerifier(address)`) | `#[only_admin]` | New rules list the token in `linked_tokens`, and rules that answer `vault()` and `setup_open()` name the token's vault with their setup closed (`ComplianceNotBound`); rules that do not answer them count as an external module and are accepted. A new verifier's `verify_identity` accepts the vault's address (`VerifierRefusesVault`). Rules that do not answer `linked_tokens` and a verifier that does not answer fail | `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::rules_from_outside_the_kit_are_installed_while_they_list_the_token`, `test_governance::rules_that_stop_answering_a_binding_view_are_installed_as_external`, `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 d52064d5..56bca23a 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -156,7 +156,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` | +| The share token swaps to new compliance rules only if they list the token and, when they answer `vault()` and `setup_open()`, run on the vault's clock with their setup closed (`ComplianceNotBound`); as at setup, rules that do not answer them count as an external module and are accepted. It swaps to a new verifier only if the verifier accepts the vault (`VerifierRefusesVault`). Both are checked when proposed and again when applied; rules that do not answer `linked_tokens` and a verifier that does not answer are 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`, `rules_from_outside_the_kit_are_installed_while_they_list_the_token`, `rules_that_stop_answering_a_binding_view_are_installed_as_external`, `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 68c03afc..a84e3beb 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -100,7 +100,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. + - A compliance swap needs a contract that lists the token in `linked_tokens`; one that answers `vault()` and `setup_open()` must also name the token's vault with its own setup finished (`ComplianceNotBound`). As at `finish_setup`, one that does not answer them is an external module and is accepted. 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 `linked_tokens` or `verify_identity` 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`. @@ -535,12 +535,12 @@ 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`. +After setup, the share token makes the same check before it swaps a contract, +when the swap is proposed and again when it applies. New compliance rules must +list the token in `linked_tokens`, and rules that answer `VaultBinding` must +name the token's vault with their setup closed. Like `finish_setup`, the token +accepts an external module that does not answer `VaultBinding`, on trust. A new +identity verifier must accept the vault's address in `verify_identity`. --- diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index 4366c0c6..19648541 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -289,9 +289,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. 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 | +| **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 list the token and, if they answer `VaultBinding`, run on the vault's clock; as at setup, an external module that does not answer is accepted on trust. A new verifier must accept the vault. Both are checked 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 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 | +| **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 list the token. OZ's compliance contract does not answer `VaultBinding`, so the swap, like the vault's setup, accepts it as an external module whose own governance is a trust assumption | | **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 | @@ -628,7 +628,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; 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. | +| 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 list the token and, if they answer `VaultBinding`, run on that clock (an external module that does not answer is taken on trust, as at setup), 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 4103bb46090e0313134f10e419a60841b319e71b Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:08:25 -0300 Subject: [PATCH 4/5] test(token): pin the exact compliance swap rule A compliance contract that answers vault() with None now has a test: the swap is refused at apply. A rule that treated that answer as external would have passed every test. The test for a contract that stops answering one binding view now gives the other view an answer that would fail the check. It passes only under the vault's rule: once either view fails, the other is ignored. The guard test that said every rule is refused before a vault is bound now says it refuses rules that name a vault before one is bound. The vault's external stub comment says it has no binding view. --- contracts/async-vault/src/test/setup.rs | 2 +- contracts/share-token/src/test_governance.rs | 37 +++++++++++++------- contracts/share-token/src/test_guards.rs | 2 +- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/contracts/async-vault/src/test/setup.rs b/contracts/async-vault/src/test/setup.rs index bd798e68..83b0133a 100644 --- a/contracts/async-vault/src/test/setup.rs +++ b/contracts/async-vault/src/test/setup.rs @@ -81,7 +81,7 @@ fn every_wiring_step_is_checked_before_setup_finishes() { } } -/// A compliance contract from outside the kit: it lists tokens and reports no vault. +/// A compliance contract from outside the kit, with no binding view. #[contract] struct ExternalRules; diff --git a/contracts/share-token/src/test_governance.rs b/contracts/share-token/src/test_governance.rs index 53c26090..df254c38 100644 --- a/contracts/share-token/src/test_governance.rs +++ b/contracts/share-token/src/test_governance.rs @@ -106,8 +106,7 @@ impl strata_bindings::VaultBinding for RulesStub { } } -/// Compliance rules from outside the kit, such as OpenZeppelin's: they list -/// the tokens a test sets and answer no binding view. +/// Rules from outside the kit, such as OpenZeppelin's, with no binding view. #[contract] struct ExternalRules; @@ -783,6 +782,7 @@ fn rules_that_stop_qualifying_are_never_installed() { 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(&None, &false, &listed)); refused_once_the_rules_turn(&f, |rules| rules.report(&here, &true, &listed)); refused_once_the_rules_turn(&f, |rules| rules.silence()); } @@ -808,22 +808,33 @@ fn rules_from_outside_the_kit_are_installed_while_they_list_the_token() { assert_eq!(f.token.compliance(), kept.address); } -/// As at the vault's setup, a contract that fails either binding view counts -/// as external, so only its `linked_tokens` decides. +/// Like `refused_once_the_rules_turn`, but the swap still lands. +fn installed_once_the_rules_turn(f: &Fixture, turn: impl FnOnce(&RulesStubClient)) { + let rules = f.rules_stub_bound_here(); + let id = f.propose(TokenChange::Compliance(rules.address.clone())); + turn(&rules); + f.advance(MIN_DELAY); + + f.token.apply_change(&id); + assert_eq!(f.token.compliance(), rules.address); +} + +/// As at the vault's setup, once one view fails, the other is ignored. #[test] fn rules_that_stop_answering_a_binding_view_are_installed_as_external() { let f = setup(); f.finish(); + let here = Some(f.vault.address.clone()); + let listed = vec![&f.e, f.token.address.clone()]; - for view in [symbol_short!("vault"), symbol_short!("open")] { - let rules = f.rules_stub_bound_here(); - let id = f.propose(TokenChange::Compliance(rules.address.clone())); - rules.forget(&view); - f.advance(MIN_DELAY); - - f.token.apply_change(&id); - assert_eq!(f.token.compliance(), rules.address); - } + installed_once_the_rules_turn(&f, |rules| { + rules.report(&Some(clock_stub(&f.e).address), &false, &listed); + rules.forget(&symbol_short!("open")); + }); + installed_once_the_rules_turn(&f, |rules| { + rules.report(&here, &true, &listed); + rules.forget(&symbol_short!("vault")); + }); } #[test] diff --git a/contracts/share-token/src/test_guards.rs b/contracts/share-token/src/test_guards.rs index acb2a635..6c99d594 100644 --- a/contracts/share-token/src/test_guards.rs +++ b/contracts/share-token/src/test_guards.rs @@ -147,7 +147,7 @@ fn not_a_second_upgrade_refuses_an_upgrade_while_one_waits() { } #[test] -fn compliance_bound_here_refuses_rules_without_the_token_or_before_the_vault_is_known() { +fn compliance_bound_here_refuses_rules_without_the_token_or_naming_a_vault_before_one_is_bound() { let f = setup(); let listing = f.rules_listing(&[&f.token.address]); refuses(&f, ShareTokenError::ComplianceNotBound, || { From 479f3a4eb7d5f85c4cc2064305b341a76cc901e5 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:08:44 -0300 Subject: [PATCH 5/5] refactor(token): clarify the compliance check on_the_vaults_clock returned true for an external module, which is not on the vault's clock. It is now bound_here_or_external, and the vault's matching local has the same name. The comment on the catch-all arm now says that either view failing marks an external module, not only a contract that answers neither. The ComplianceNotBound and propose_change docs said "another vault or an open setup", which left out a contract that names no vault. They now say the contract does not name this token's vault with its setup closed. --- contracts/async-vault/src/governance.rs | 6 +++--- contracts/share-token/src/contract.rs | 3 ++- contracts/share-token/src/error.rs | 7 ++++--- contracts/share-token/src/guards.rs | 11 ++++------- 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/contracts/async-vault/src/governance.rs b/contracts/async-vault/src/governance.rs index 1accb496..8a4152ea 100644 --- a/contracts/async-vault/src/governance.rs +++ b/contracts/async-vault/src/governance.rs @@ -144,12 +144,12 @@ fn compliance_bound(e: &Env, compliance: &Address, token: &Address) -> bool { Ok(Ok(linked)) if linked.contains(token) ); let binding = VaultBindingClient::new(e, compliance); - let on_this_clock = match (binding.try_vault(), binding.try_setup_open()) { + let bound_here_or_external = match (binding.try_vault(), binding.try_setup_open()) { (Ok(Ok(vault)), Ok(Ok(open))) => vault == Some(e.current_contract_address()) && !open, - // An external module answers no binding view; its governance is a trust assumption. + // Either view failing marks an external module; its governance is a trust assumption. _ => true, }; - linked && on_this_clock + linked && bound_here_or_external } fn all_different(addresses: &[&Address]) -> bool { diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index f743e051..c919fae0 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -127,7 +127,8 @@ impl ShareToken { /// # Errors /// /// * `ComplianceNotBound` - The new compliance contract does not list - /// this token, or reports another vault or an open setup. + /// this token, or answers `VaultBinding` but does not name this token's + /// vault 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. diff --git a/contracts/share-token/src/error.rs b/contracts/share-token/src/error.rs index eb84b777..4a29c6a1 100644 --- a/contracts/share-token/src/error.rs +++ b/contracts/share-token/src/error.rs @@ -56,9 +56,10 @@ strata_bindings::error_enum! { AdminTransferPending = 7016, /// The proposed compliance contract's `linked_tokens` leaves out this /// token, so every mint, transfer and burn would fail. Or it answers - /// `vault()` and `setup_open()` with another vault or an open setup, so - /// its rules could change without the vault's delay. One that does not - /// answer them is an external module and passes, on trust. + /// `vault()` and `setup_open()` but does not name this token's vault + /// with its setup closed, so its rules could change without the + /// vault's delay. One that leaves either view unanswered is an + /// external module and passes, on trust. ComplianceNotBound = 7017, /// The proposed identity verifier refuses the vault's address or does /// not answer `verify_identity`. The vault needs that check to convert diff --git a/contracts/share-token/src/guards.rs b/contracts/share-token/src/guards.rs index 0a42d542..62b07327 100644 --- a/contracts/share-token/src/guards.rs +++ b/contracts/share-token/src/guards.rs @@ -77,10 +77,9 @@ pub(crate) fn valid_change(e: &Env, change: &TokenChange) { } } -/// Rules that leave out this token stop every mint, transfer and burn; rules -/// that report another vault or an open setup could change with no delay. +/// The same rule the vault's `finish_setup` applies to compliance contracts. pub(crate) fn compliance_bound_here(e: &Env, rules: &Address) { - if !on_the_vaults_clock(e, rules) || !lists_this_token(e, rules) { + if !bound_here_or_external(e, rules) || !lists_this_token(e, rules) { panic_with_error!(e, ShareTokenError::ComplianceNotBound); } } @@ -97,15 +96,13 @@ pub(crate) fn verifier_accepts_vault(e: &Env, verifier: &Address) { } } -/// When `contract` answers both binding views, they name this token's vault -/// with its setup closed. -fn on_the_vaults_clock(e: &Env, contract: &Address) -> bool { +fn bound_here_or_external(e: &Env, contract: &Address) -> bool { let binding = VaultBindingClient::new(e, contract); match (binding.try_vault(), binding.try_setup_open()) { (Ok(Ok(named)), Ok(Ok(open))) => { setup::source(e).is_some_and(|vault| named == Some(vault)) && !open } - // An external module answers no binding view; its governance is a trust assumption. + // Either view failing marks an external module; its governance is a trust assumption. _ => true, } }