diff --git a/README.md b/README.md index bbb36ca..24a766b 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ vault is allowlisted share_token bound to compliance nav_oracle bound to the vault, setup finished vault setup finished -health {"allowlisted":true,"compliance_bound":true,"feed_valid":false,"freshness_covers_notice":true,"oracle_bound":true,"setup_open":false,"sole_token_manager":true,"token_bound":true} +health {"allowlisted":true,"compliance_bound":true,"feed_valid":false,"freshness_covers_notice":true,"oracle_bound":true,"setup_open":false,"sole_token_manager":true,"token_bound":true,"vault_unfrozen":true} wrote deployed.local.json ``` diff --git a/contracts/async-vault/src/governance.rs b/contracts/async-vault/src/governance.rs index df16845..b51473d 100644 --- a/contracts/async-vault/src/governance.rs +++ b/contracts/async-vault/src/governance.rs @@ -68,6 +68,7 @@ pub(crate) struct Wiring { /// Shares minted before setup would redeem against depositors' money. pub no_shares_outstanding: bool, pub allowlisted: bool, + pub vault_unfrozen: bool, pub compliance_bound: bool, } @@ -78,6 +79,7 @@ impl Wiring { && self.sole_token_manager && self.no_shares_outstanding && self.allowlisted + && self.vault_unfrozen && self.compliance_bound } } @@ -168,6 +170,7 @@ pub(crate) fn wiring(e: &Env) -> Wiring { ), _ => false, }, + vault_unfrozen: matches!(rwa.try_is_frozen(&me), Ok(Ok(false))), compliance_bound: match rwa.try_compliance() { Ok(Ok(compliance)) => matches!( TokenBinderClient::new(e, &compliance).try_linked_tokens(), diff --git a/contracts/async-vault/src/health.rs b/contracts/async-vault/src/health.rs index c076842..5b6ec76 100644 --- a/contracts/async-vault/src/health.rs +++ b/contracts/async-vault/src/health.rs @@ -17,6 +17,7 @@ pub struct Health { pub oracle_bound: bool, pub sole_token_manager: bool, pub allowlisted: bool, + pub vault_unfrozen: bool, pub compliance_bound: bool, pub feed_valid: bool, /// A report outlives the notice, so an epoch's cancellation window does @@ -34,6 +35,7 @@ pub(crate) fn health(e: &Env) -> Health { oracle_bound: wiring.oracle_bound, sole_token_manager: wiring.sole_token_manager, allowlisted: wiring.allowlisted, + vault_unfrozen: wiring.vault_unfrozen, compliance_bound: wiring.compliance_bound, feed_valid: matches!(feed.try_state(), Ok(Ok(OracleState::Valid))), freshness_covers_notice: matches!( diff --git a/contracts/async-vault/src/test/health.rs b/contracts/async-vault/src/test/health.rs index 07bf6a4..7a7ce66 100644 --- a/contracts/async-vault/src/test/health.rs +++ b/contracts/async-vault/src/test/health.rs @@ -8,6 +8,7 @@ fn all_healthy() -> Health { oracle_bound: true, sole_token_manager: true, allowlisted: true, + vault_unfrozen: true, compliance_bound: true, feed_valid: true, freshness_covers_notice: true, @@ -74,6 +75,13 @@ fn each_missing_wiring_step_shows_in_its_own_flag() { ..all_healthy() }, ), + ( + Miswire::VaultFrozen, + Health { + vault_unfrozen: false, + ..all_healthy() + }, + ), ( Miswire::ComplianceUnbound, Health { diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index e2978d9..7e92e11 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -235,6 +235,7 @@ enum Miswire { SecondManager, PreMinted, NotAllowlisted, + VaultFrozen, ComplianceUnbound, } @@ -361,6 +362,10 @@ fn build<'a>( if miswire != Miswire::NotAllowlisted { identity.allow(&contract_id, &true, &compliance_authority); } + if miswire == Miswire::VaultFrozen { + // Until `bind_vault`, the token does not know this address is its vault. + share.set_address_frozen(&contract_id, &true, &compliance_authority); + } match miswire { Miswire::TokenUnbound => {} Miswire::TokenBoundElsewhere => { diff --git a/contracts/async-vault/src/test/setup.rs b/contracts/async-vault/src/test/setup.rs index 2e1c161..2890fb7 100644 --- a/contracts/async-vault/src/test/setup.rs +++ b/contracts/async-vault/src/test/setup.rs @@ -60,6 +60,7 @@ fn every_wiring_step_is_checked_before_setup_finishes() { Miswire::SecondManager, Miswire::PreMinted, Miswire::NotAllowlisted, + Miswire::VaultFrozen, Miswire::ComplianceUnbound, ] { let f = miswired(miswire); @@ -131,6 +132,7 @@ fn a_token_that_cannot_answer_fails_its_checks_instead_of_trapping() { assert!(!health.token_bound); assert!(!health.sole_token_manager); assert!(!health.allowlisted); + assert!(!health.vault_unfrozen); assert!(!health.compliance_bound); refused(vault.try_finish_setup(&admin), VaultError::SetupIncomplete); diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index 29394bd..acfbe49 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -1,4 +1,5 @@ use crate::error::ShareTokenError; +use crate::event::LegalTransfer; use crate::governance::{self, TokenChange, TokenPendingChange}; use crate::roles::COMPLIANCE_ROLE; @@ -9,12 +10,29 @@ use soroban_sdk::{ use stellar_access::access_control::{self as access_control, AccessControl}; use stellar_contract_utils::pausable::{self as pausable, Pausable}; use stellar_contract_utils::upgradeable; -use stellar_macros::{only_admin, only_any_role, only_role}; +use stellar_macros::{only_admin, only_role}; use stellar_tokens::{ fungible::{Base, ContractOverrides, FungibleToken}, - rwa::{RWAToken, RWA}, + rwa::{identity_verification::IdentityVerifierClient, RWAToken, RWA}, }; +/// Longest order reference a legal transfer accepts, in bytes. +pub const MAX_REFERENCE_LEN: u32 = 128; + +/// The co-signing admin must be a different account from the operator. +fn require_admin_co_sign(e: &Env, operator: &Address) { + let admin = access_control::enforce_admin_auth(e); + if admin == *operator { + panic_with_error!(e, ShareTokenError::CoSignRequired); + } +} + +fn refuse_vault_address(e: &Env, account: &Address) { + if timelock::setup::source(e).as_ref() == Some(account) { + panic_with_error!(e, ShareTokenError::VaultAddress); + } +} + #[contract] pub struct ShareToken; @@ -91,6 +109,36 @@ impl ShareToken { pub fn pending_changes(e: &Env) -> Vec { governance::pending(e) } + + /// Moves shares under a legal order, at once. The compliance authority + /// asks and the admin co-signs the same call. Never out of or into the + /// vault's address, and only to an account the identity verifier accepts. + #[only_role(operator, "compliance")] + pub fn legal_transfer( + e: &Env, + from: Address, + to: Address, + amount: i128, + reference: String, + operator: Address, + ) { + storage::bump_instance(e); + require_admin_co_sign(e, &operator); + if reference.is_empty() || reference.len() > MAX_REFERENCE_LEN { + panic_with_error!(e, ShareTokenError::InvalidReference); + } + refuse_vault_address(e, &from); + refuse_vault_address(e, &to); + IdentityVerifierClient::new(e, &RWA::identity_verifier(e)).verify_identity(&to); + RWA::forced_transfer(e, &from, &to, amount); + LegalTransfer { + from, + to, + amount, + reference, + } + .publish(e); + } } #[contractimpl(contracttrait)] @@ -201,12 +249,16 @@ impl RWAToken for ShareToken { storage::bump_instance(e); RWA::burn(e, &user_address, amount); } - /// The vault escrows redemptions through it; compliance intervenes with it. - #[only_any_role(operator, ["manager", "compliance"])] + /// The vault escrows redemptions through it. Compliance moves shares with + /// `legal_transfer` instead. + #[only_role(operator, "manager")] fn forced_transfer(e: &Env, from: Address, to: Address, amount: i128, operator: Address) { storage::bump_instance(e); RWA::forced_transfer(e, &from, &to, amount); } + /// Moves a whole balance to the account the identity verifier names, so + /// it needs the admin's co-signature like a legal transfer, and never + /// moves shares out of or into the vault's address. #[only_role(operator, "compliance")] fn recover_balance( e: &Env, @@ -215,16 +267,23 @@ impl RWAToken for ShareToken { operator: Address, ) -> bool { storage::bump_instance(e); + require_admin_co_sign(e, &operator); + refuse_vault_address(e, &old_account); + refuse_vault_address(e, &new_account); RWA::recover_balance(e, &old_account, &new_account) } #[only_role(operator, "compliance")] fn set_address_frozen(e: &Env, user_address: Address, freeze: bool, operator: Address) { storage::bump_instance(e); + if freeze { + refuse_vault_address(e, &user_address); + } RWA::set_address_frozen(e, &user_address, freeze); } #[only_role(operator, "compliance")] fn freeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address) { storage::bump_instance(e); + refuse_vault_address(e, &user_address); RWA::freeze_partial_tokens(e, &user_address, amount); } #[only_role(operator, "compliance")] diff --git a/contracts/share-token/src/error.rs b/contracts/share-token/src/error.rs index b3b950b..31353a6 100644 --- a/contracts/share-token/src/error.rs +++ b/contracts/share-token/src/error.rs @@ -35,6 +35,16 @@ pub enum ShareTokenError { DelayBelowNotice = 7010, /// An upgrade already waits. Cancel it before proposing another. UpgradeAlreadyPending = 7011, + /// The vault's own address cannot be frozen, and a legal transfer or a + /// recovery never moves shares out of it or into it: its balance is other + /// investors' escrow. + VaultAddress = 7012, + /// A legal transfer or a recovery needs the compliance authority and the + /// admin as two different accounts, so no single key moves value. + CoSignRequired = 7013, + /// A legal transfer names the order behind it: a reference of 1 to 128 + /// bytes. + InvalidReference = 7015, /// An admin hand-over offered during setup is still open. Setup finishes /// once it is accepted, cancelled or expired, so no nominee from setup can /// accept after changes start to wait. diff --git a/contracts/share-token/src/event.rs b/contracts/share-token/src/event.rs index 524c24a..6a08551 100644 --- a/contracts/share-token/src/event.rs +++ b/contracts/share-token/src/event.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contractevent, Address}; +use soroban_sdk::{contractevent, Address, String}; use crate::governance::TokenChange; @@ -31,3 +31,13 @@ pub struct VaultBound { #[topic] pub vault: Address, } + +#[contractevent] +pub struct LegalTransfer { + #[topic] + pub from: Address, + #[topic] + pub to: Address, + pub amount: i128, + pub reference: String, +} diff --git a/contracts/share-token/src/lib.rs b/contracts/share-token/src/lib.rs index 5d530e0..a4ac4d7 100644 --- a/contracts/share-token/src/lib.rs +++ b/contracts/share-token/src/lib.rs @@ -6,7 +6,9 @@ mod governance; mod roles; pub use contract::*; pub use error::ShareTokenError; -pub use event::{ChangeApplied, ChangeCancelled, ChangeProposed, SetupFinished, VaultBound}; +pub use event::{ + ChangeApplied, ChangeCancelled, ChangeProposed, LegalTransfer, SetupFinished, VaultBound, +}; pub use governance::{TokenChange, TokenPendingChange, ADMIN_ACCEPT_LEDGERS}; #[cfg(test)] diff --git a/contracts/share-token/src/roles.rs b/contracts/share-token/src/roles.rs index af118c8..1893c79 100644 --- a/contracts/share-token/src/roles.rs +++ b/contracts/share-token/src/roles.rs @@ -1,8 +1,9 @@ use soroban_sdk::{symbol_short, Symbol}; -/// Role for allowlist-driven interventions: freeze, forced transfer, recovery. -/// Held by the compliance authority. Minting, burning and escrow use the -/// `manager` role, which governance grants to the vault. +/// Role for allowlist-driven interventions: freezes, plus legal transfers and +/// recovery with the admin's co-signature. Held by the compliance authority. +/// Minting, burning and escrow use the `manager` role, which governance grants +/// to the vault. pub const COMPLIANCE_ROLE: &str = "compliance"; /// The vault's role. Without it the vault cannot mint or burn, so no claim pays. diff --git a/contracts/share-token/src/test.rs b/contracts/share-token/src/test.rs index 947798e..7d7b7c6 100644 --- a/contracts/share-token/src/test.rs +++ b/contracts/share-token/src/test.rs @@ -298,8 +298,14 @@ fn the_compliance_authority_freezes_and_moves_balances() { .unfreeze_partial_tokens(&holder, &400, &f.compliance_authority); assert_eq!(f.token.get_frozen_tokens(&holder), 0); - f.token - .forced_transfer(&holder, &target, &250, &f.compliance_authority); + identity.allow(&target, &true, &f.admin); + f.token.legal_transfer( + &holder, + &target, + &250, + &String::from_str(&e, "court-order-2026-117"), + &f.compliance_authority, + ); assert_eq!(f.token.balance(&target), 250); } @@ -510,3 +516,418 @@ fn compliance_interventions_restore_the_instance_ttl() { assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); } + +#[test] +fn a_legal_transfer_restores_the_instance_ttl() { + let e = Env::default(); + let (f, holder, other) = decayed_live_token(&e); + + f.token.legal_transfer( + &holder, + &other, + &10, + &String::from_str(&e, "court-order-2026-117"), + &f.compliance_authority, + ); + + assert_eq!(instance_ttl(&e, &f.token.address), INSTANCE_EXTEND_AMOUNT); +} + +mod legal_transfer { + use soroban_sdk::{ + contract, contractimpl, contracttype, + testutils::Events as _, + xdr::{ScErrorCode, ScErrorType}, + Event as _, + }; + use stellar_tokens::rwa::identity_verification; + use timelock::{Clock, MIN_DELAY}; + + use super::*; + use crate::{LegalTransfer, ShareTokenError, MAX_REFERENCE_LEN}; + + /// Plays the vault: the token binds only to an address that reports a + /// clock. + #[contract] + struct VaultStub; + + #[contractimpl(contracttrait)] + impl timelock::ClockSource for VaultStub { + fn timelock_clock(_e: &Env) -> Clock { + Clock { + delay: MIN_DELAY, + exit_window: 0, + paused_secs: 0, + paused: false, + winding_down: false, + } + } + } + + fn order(e: &Env) -> String { + String::from_str(e, "court-order-2026-117") + } + + /// Real rules and verifier, a holder with 1,000 shares, an allowlisted + /// destination and a bound vault that holds 500 shares of escrow. + fn live(e: &Env) -> (Fixture<'_>, Address, Address, Address) { + let (f, identity) = setup_live(e); + let holder = Address::generate(e); + let destination = Address::generate(e); + let vault = e.register(VaultStub, ()); + for who in [&holder, &destination, &vault] { + identity.allow(who, &true, &f.admin); + } + f.token.mint(&holder, &1_000, &f.manager); + f.token.mint(&vault, &500, &f.manager); + f.token.bind_vault(&vault, &f.admin); + (f, holder, destination, vault) + } + + /// A missing signature fails in the host, not as a contract error. + fn missing_signature( + result: Result, Result>, + ) { + assert_eq!( + result.err(), + Some(Ok(soroban_sdk::Error::from_type_and_code( + ScErrorType::Context, + ScErrorCode::InvalidAction + ))), + ); + } + + #[test] + fn compliance_and_the_admin_move_shares_together() { + let e = Env::default(); + let (f, holder, destination, _) = live(&e); + + f.token.legal_transfer( + &holder, + &destination, + &250, + &order(&e), + &f.compliance_authority, + ); + + let events = e.events().all().filter_by_contract(&f.token.address); + assert_eq!( + events.events().last(), + Some( + &LegalTransfer { + from: holder.clone(), + to: destination.clone(), + amount: 250, + reference: order(&e), + } + .to_xdr(&e, &f.token.address) + ) + ); + assert_eq!(f.token.balance(&holder), 750); + assert_eq!(f.token.balance(&destination), 250); + } + + #[test] + fn compliance_alone_cannot_move_shares() { + let e = Env::default(); + let (f, holder, destination, _) = live(&e); + let invoke = MockAuthInvoke { + contract: &f.token.address, + fn_name: "legal_transfer", + args: ( + holder.clone(), + destination.clone(), + 250i128, + order(&e), + f.compliance_authority.clone(), + ) + .into_val(&e), + sub_invokes: &[], + }; + let transfer = || { + f.token.try_legal_transfer( + &holder, + &destination, + &250, + &order(&e), + &f.compliance_authority, + ) + }; + + e.mock_auths(&[MockAuth { + address: &f.compliance_authority, + invoke: &invoke, + }]); + missing_signature(transfer()); + assert_eq!(f.token.balance(&holder), 1_000); + + e.mock_auths(&[ + MockAuth { + address: &f.compliance_authority, + invoke: &invoke, + }, + MockAuth { + address: &f.admin, + invoke: &invoke, + }, + ]); + assert_eq!(transfer(), Ok(Ok(()))); + assert_eq!(f.token.balance(&destination), 250); + } + + #[test] + fn nothing_leaves_the_vault_address() { + let e = Env::default(); + let (f, _, destination, vault) = live(&e); + + assert_eq!( + f.token + .try_legal_transfer( + &vault, + &destination, + &100, + &order(&e), + &f.compliance_authority + ) + .err(), + Some(Ok(ShareTokenError::VaultAddress.into())), + ); + assert_eq!(f.token.balance(&vault), 500); + } + + #[test] + fn nothing_lands_on_the_vault_address() { + let e = Env::default(); + let (f, holder, _, vault) = live(&e); + + assert_eq!( + f.token + .try_legal_transfer(&holder, &vault, &100, &order(&e), &f.compliance_authority) + .err(), + Some(Ok(ShareTokenError::VaultAddress.into())), + ); + assert_eq!(f.token.balance(&holder), 1_000); + } + + #[test] + fn the_destination_must_pass_the_identity_check() { + let e = Env::default(); + let (f, holder, _, _) = live(&e); + let stranger = Address::generate(&e); + + assert_eq!( + f.token + .try_legal_transfer( + &holder, + &stranger, + &100, + &order(&e), + &f.compliance_authority + ) + .err(), + Some(Ok(RWAError::IdentityVerificationFailed.into())), + ); + assert_eq!(f.token.balance(&holder), 1_000); + } + + #[test] + fn the_order_reference_is_required_and_bounded() { + let e = Env::default(); + let (f, holder, destination, _) = live(&e); + let bytes = [b'x'; MAX_REFERENCE_LEN as usize + 1]; + let reference_of = |len: u32| String::from_bytes(&e, &bytes[..len as usize]); + + for reference in [reference_of(0), reference_of(MAX_REFERENCE_LEN + 1)] { + assert_eq!( + f.token + .try_legal_transfer( + &holder, + &destination, + &100, + &reference, + &f.compliance_authority + ) + .err(), + Some(Ok(ShareTokenError::InvalidReference.into())), + ); + } + + for reference in [reference_of(1), reference_of(MAX_REFERENCE_LEN)] { + f.token.legal_transfer( + &holder, + &destination, + &100, + &reference, + &f.compliance_authority, + ); + } + assert_eq!(f.token.balance(&destination), 200); + } + + #[test] + fn one_account_cannot_co_sign_with_itself() { + let e = Env::default(); + let (f, holder, destination, _) = live(&e); + f.token + .grant_role(&f.admin, &Symbol::new(&e, "compliance"), &f.admin); + + assert_eq!( + f.token + .try_legal_transfer(&holder, &destination, &100, &order(&e), &f.admin) + .err(), + Some(Ok(ShareTokenError::CoSignRequired.into())), + ); + assert_eq!(f.token.balance(&holder), 1_000); + } + + #[test] + fn compliance_no_longer_forces_transfers() { + let e = Env::default(); + let (f, holder, destination, _) = live(&e); + + assert_unauthorized(f.token.try_forced_transfer( + &holder, + &destination, + &100, + &f.compliance_authority, + )); + } + + /// Accepts every account and names one recovery target; the kit's + /// verifier names none, so no recovery could succeed with it. + #[contract] + struct RecoveringVerifier; + + #[contracttype] + enum RecoveryKey { + Target, + } + + #[contractimpl] + impl RecoveringVerifier { + pub fn set_target(e: Env, target: Address) { + e.storage().instance().set(&RecoveryKey::Target, &target); + } + } + + #[contractimpl(contracttrait)] + impl identity_verification::IdentityVerifier for RecoveringVerifier { + fn verify_identity(_e: &Env, _account: &Address) {} + + fn recovery_target(e: &Env, _old_account: &Address) -> Option
{ + e.storage().instance().get(&RecoveryKey::Target) + } + + fn set_claim_topics_and_issuers( + _e: &Env, + _claim_topics_and_issuers: Address, + _operator: Address, + ) { + } + } + + /// The live fixture's setup is still open, so the verifier swap is instant. + fn recovering_to(e: &Env, f: &Fixture, target: &Address) { + let verifier = RecoveringVerifierClient::new(e, &e.register(RecoveringVerifier, ())); + verifier.set_target(target); + f.token.set_identity_verifier(&verifier.address, &f.admin); + } + + #[test] + fn recovery_needs_the_admin_too() { + let e = Env::default(); + let (f, holder, destination, _) = live(&e); + recovering_to(&e, &f, &destination); + let invoke = MockAuthInvoke { + contract: &f.token.address, + fn_name: "recover_balance", + args: ( + holder.clone(), + destination.clone(), + f.compliance_authority.clone(), + ) + .into_val(&e), + sub_invokes: &[], + }; + + e.mock_auths(&[MockAuth { + address: &f.compliance_authority, + invoke: &invoke, + }]); + missing_signature(f.token.try_recover_balance( + &holder, + &destination, + &f.compliance_authority, + )); + assert_eq!(f.token.balance(&holder), 1_000); + + e.mock_auths(&[ + MockAuth { + address: &f.compliance_authority, + invoke: &invoke, + }, + MockAuth { + address: &f.admin, + invoke: &invoke, + }, + ]); + assert!(f + .token + .recover_balance(&holder, &destination, &f.compliance_authority)); + assert_eq!(f.token.balance(&holder), 0); + assert_eq!(f.token.balance(&destination), 1_000); + } + + #[test] + fn recovery_never_takes_the_vault_balance() { + let e = Env::default(); + let (f, _, destination, vault) = live(&e); + recovering_to(&e, &f, &destination); + + assert_eq!( + f.token + .try_recover_balance(&vault, &destination, &f.compliance_authority) + .err(), + Some(Ok(ShareTokenError::VaultAddress.into())), + ); + assert_eq!(f.token.balance(&vault), 500); + } + + #[test] + fn recovery_never_lands_on_the_vault_address() { + let e = Env::default(); + let (f, holder, _, vault) = live(&e); + recovering_to(&e, &f, &vault); + + assert_eq!( + f.token + .try_recover_balance(&holder, &vault, &f.compliance_authority) + .err(), + Some(Ok(ShareTokenError::VaultAddress.into())), + ); + assert_eq!(f.token.balance(&holder), 1_000); + } + + #[test] + fn the_vault_address_cannot_be_frozen() { + let e = Env::default(); + let (f, _, _, vault) = live(&e); + + assert_eq!( + f.token + .try_set_address_frozen(&vault, &true, &f.compliance_authority) + .err(), + Some(Ok(ShareTokenError::VaultAddress.into())), + ); + assert_eq!( + f.token + .try_freeze_partial_tokens(&vault, &100, &f.compliance_authority) + .err(), + Some(Ok(ShareTokenError::VaultAddress.into())), + ); + f.token + .set_address_frozen(&vault, &false, &f.compliance_authority); + assert!(!f.token.is_frozen(&vault)); + assert_eq!(f.token.get_frozen_tokens(&vault), 0); + } +} diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index eff093f..e7645aa 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -10,12 +10,12 @@ This document provides the complete authorization and role model specification f 2. **Strict Caller Authentication**: No entrypoint takes a caller argument that it does not authenticate. Every function accepting a `caller: Address` executes `caller.require_auth()`. 3. **Exit guarantee, with one hold**: A covered redemption claim pays out cash regardless of pause, staleness or allowlist status. A delisted investor exits through the cash path and cannot re-enter share circulation. A frozen investor is held: the vault refuses their requests, cancellations and claims, and pays them only after the freeze lifts. 4. **Five Separate Authorities**: - - **Governance (Admin)**: Root authority; upgrades, timelock parameters, custodian settings, unpause, oracle parameters, emergency NAV pause reset. + - **Governance (Admin)**: Root authority; upgrades, timelock parameters, custodian settings, unpause, oracle parameters, emergency NAV pause reset. As the share token's admin, it co-signs compliance's `legal_transfer` and `recover_balance`. - **Manager (Vault)**: Closes epochs. - **Treasury**: Deploys free reserve assets to custodian. - **Guardian**: Emergency responder; pauses vault entries, pauses the oracle's NAV. - **Attester (Oracle)**: Submits off-chain valuation reports within strict deviation and rate bounds. - - **Compliance**: Writes the allowlist on the identity verifier and holds the share token's `compliance` role for freezes, forced transfers and recovery. Cannot mint, burn or swap the token's compliance or identity-verifier contract. + - **Compliance**: Writes the allowlist on the identity verifier and holds the share token's `compliance` role for freezes and partial freezes. It moves shares only with `legal_transfer` or `recover_balance`, each needing governance's co-signature in the same call, and never into or out of the vault's address. Cannot mint, burn, call `forced_transfer`, or swap the token's compliance or identity-verifier contract. --- @@ -28,7 +28,7 @@ The symbol `"manager"` names two completely distinct roles living on separate co | **`AsyncVault`** | `symbol_short!("manager")` | Operator Multisig / Automation Bot | Strictly calls `close_epoch` to freeze orders for pricing. Holds **no** token minting or burning power. | | **`ShareToken`** | `symbol_short!("manager")` | `AsyncVault` Contract Instance | Authorized to call `mint` (claim deposits), `burn` (claim redemptions) and `forced_transfer` (escrow redemptions). Holds no freeze or recovery power. | -The share token has a third role, `"compliance"`, held by the compliance authority: freezes, partial freezes, recovery and `forced_transfer`. `forced_transfer` is the one entrypoint both roles reach, because the vault escrows redemptions with it. +The share token has a third role, `"compliance"`, held by the compliance authority: freezes and partial freezes, plus `legal_transfer` and `recover_balance` with governance's co-signature. Only `manager` reaches `forced_transfer`, which the vault uses to escrow redemptions. ### Deployment Script Safety In `scripts/harness/deploy.ts` and automated deployment scripts, the separation is enforced structurally: @@ -38,7 +38,7 @@ In `scripts/harness/deploy.ts` and automated deployment scripts, the separation - After granting, the script checks `token.has_role({ account: vault, role: "manager" })` and stops if the vault does not hold the role. That no other account holds it is checked by the vault's `finish_setup` below. - It then calls `token.bind_vault({ vault, caller: governance })` and `token.finish_setup({ caller: governance })`, and stops if `token.setup_open()` still reads true. From then on the token's admin changes wait for the vault's timelock (§5.2). - It calls `oracle.bind_vault({ vault, caller: governance })` and `oracle.finish_setup({ caller: governance })`, and stops if `oracle.setup_open()` still reads true. From then on the oracle's admin changes wait for the vault's timelock (§5.3). -- Last, it calls `vault.set_custodian({ custodian, caller: governance })` and `vault.finish_setup({ caller: governance })`, and stops if `vault.setup_open()` still reads true. `finish_setup` refuses with `SetupIncomplete` unless the token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the token has no shares outstanding, the vault passes the identity check and the token is bound to its compliance contract. From then on the vault takes deposits and its admin changes wait for its timelock (§5.1). +- Last, it calls `vault.set_custodian({ custodian, caller: governance })` and `vault.finish_setup({ caller: governance })`, and stops if `vault.setup_open()` still reads true. `finish_setup` refuses with `SetupIncomplete` unless the token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the token has no shares outstanding, the vault passes the identity check, its address is not frozen on the token and the token is bound to its compliance contract. From then on the vault takes deposits and its admin changes wait for its timelock (§5.1). --- @@ -90,7 +90,7 @@ Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs. | `set_notice` | `#[only_admin]`, `caller.require_auth()` | `secs <= MAX_NOTICE_SECS`, `secs <= upgrade_delay`. Raising is instant; lowering is `UseTimelock` after setup | `test::governance::raising_the_notice_is_instant_and_lowering_waits`, `test::notice::setting_the_notice_needs_governance_authorisation` | | `set_wind_down_delay` | `#[only_admin]`, `caller.require_auth()` | `MIN_WIND_DOWN_DELAY <= secs <= MAX_WIND_DOWN_DELAY`. Raising is instant; lowering is `UseTimelock` after setup | `test::governance::raising_the_wind_down_delay_is_instant_and_lowering_waits`, `test::governance::a_wind_down_delay_below_the_minimum_cannot_be_proposed`, `test::wind_down::only_governance_sets_the_delay` | | `set_deposit_cap` | `#[only_admin]`, `caller.require_auth()` | Not negative. Instant both ways, also after setup | `test::governance::the_deposit_cap_stays_instant_both_ways` | -| `finish_setup` | `#[only_admin]`, `caller.require_auth()` | Once. The token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the token's `total_supply` is zero, the vault passes `verify_identity`, and the token is in its compliance contract's `linked_tokens`; no admin hand-over offered during setup is pending | `test::setup::every_wiring_step_is_checked_before_setup_finishes`, `test::setup::setup_cannot_finish_while_a_hand_over_is_pending`, `test::setup::setup_never_finishes_twice`, `test::setup::only_governance_finishes_setup` | +| `finish_setup` | `#[only_admin]`, `caller.require_auth()` | Once. The token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the token's `total_supply` is zero, the vault passes `verify_identity`, the token's `is_frozen` is false for the vault, and the token is in its compliance contract's `linked_tokens`; no admin hand-over offered during setup is pending | `test::setup::every_wiring_step_is_checked_before_setup_finishes`, `test::setup::setup_cannot_finish_while_a_hand_over_is_pending`, `test::setup::setup_never_finishes_twice`, `test::setup::only_governance_finishes_setup` | | `grant_role` / `transfer_admin_role` | Admin | Instant during setup, `UseTimelock` after. A zero `live_until_ledger` cancels instantly | `test::governance::grants_and_manager_revokes_wait_for_the_timelock`, `test::governance::an_admin_hand_over_waits_but_cancelling_one_does_not` | | `revoke_role` | Admin | `treasury`, `guardian`: instant. `manager`: instant during setup, `UseTimelock` after | `test::governance::grants_and_manager_revokes_wait_for_the_timelock` (instant case: `test::governance::revoking_treasury_or_guardian_stays_instant`; caller check: `test::governance::only_the_admin_revokes_a_role`) | | `set_role_admin` | — | Always refused (`UseTimelock`) | `test::governance::no_role_ever_gets_a_role_admin` | @@ -116,11 +116,12 @@ Tests: [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.r | `unpause` | `#[only_admin]`, `caller.require_auth()` | Paused | `test::unauthorized_caller_cannot_pause_or_unpause` | | `mint` | `#[only_role(operator, "manager")]` | Receiver allowlisted | `test::unauthorized_caller_cannot_mint`, `test::the_compliance_authority_cannot_mint_burn_or_swap_the_rules` | | `burn` | `#[only_role(operator, "manager")]` | Balance sufficient | `test::unauthorized_caller_cannot_burn` | -| `forced_transfer` | `#[only_any_role(operator, ["manager", "compliance"])]` | Balance sufficient | `test::unauthorized_caller_cannot_forced_transfer` | -| `set_address_frozen` | `#[only_role(operator, "compliance")]` | None | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_manager_escrows_but_does_not_intervene` | -| `freeze_partial_tokens` | `#[only_role(operator, "compliance")]` | Balance sufficient | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_manager_escrows_but_does_not_intervene` | +| `forced_transfer` | `#[only_role(operator, "manager")]` | Balance sufficient | `test::unauthorized_caller_cannot_forced_transfer`, `test::legal_transfer::compliance_no_longer_forces_transfers` | +| `legal_transfer` | `#[only_role(operator, "compliance")]` and the admin's `require_auth()` in the same call, from two different accounts (`CoSignRequired`) | Reference of 1–128 bytes (`InvalidReference`); never from or to the vault's address (`VaultAddress`); destination passes `verify_identity`; balance sufficient | `test::legal_transfer::*` | +| `set_address_frozen` | `#[only_role(operator, "compliance")]` | Never freezes the bound vault's address (`VaultAddress`); unfreezing it stays possible | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_manager_escrows_but_does_not_intervene`, `test::legal_transfer::the_vault_address_cannot_be_frozen` | +| `freeze_partial_tokens` | `#[only_role(operator, "compliance")]` | Balance sufficient; never the bound vault's address (`VaultAddress`) | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_manager_escrows_but_does_not_intervene`, `test::legal_transfer::the_vault_address_cannot_be_frozen` | | `unfreeze_partial_tokens`| `#[only_role(operator, "compliance")]` | Frozen amount sufficient | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_manager_escrows_but_does_not_intervene` | -| `recover_balance` | `#[only_role(operator, "compliance")]` | Target allowlisted and named by the verifier's `recovery_target` (the testnet stub names none, so recovery always fails there) | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_compliance_authority_reaches_recovery` | +| `recover_balance` | `#[only_role(operator, "compliance")]` and the admin's `require_auth()` in the same call, from two different accounts (`CoSignRequired`) | Never from or to the vault's address (`VaultAddress`); target allowlisted and named by the verifier's `recovery_target` (the testnet stub names none, so recovery always fails there) | `test::unauthorized_caller_cannot_freeze_or_recover`, `test::the_compliance_authority_reaches_recovery`, `test::legal_transfer::recovery_needs_the_admin_too`, `test::legal_transfer::recovery_never_takes_the_vault_balance`, `test::legal_transfer::recovery_never_lands_on_the_vault_address` | | `set_compliance` | `#[only_admin]`, `operator.require_auth()` | Instant during setup; `UseTimelock` after | `test::unauthorized_caller_cannot_set_compliance_or_verifier`, `test::the_compliance_authority_cannot_mint_burn_or_swap_the_rules`, `test_governance::rule_and_verifier_swaps_wait_after_setup` | | `set_identity_verifier` | `#[only_admin]`, `operator.require_auth()` | Instant during setup; `UseTimelock` after | `test::unauthorized_caller_cannot_set_compliance_or_verifier`, `test::the_compliance_authority_cannot_mint_burn_or_swap_the_rules`, `test_governance::rule_and_verifier_swaps_wait_after_setup` | | `renounce_admin` | Refused always (`ShareTokenError::AdminRequired`) | None | `test::the_admin_cannot_renounce_itself_out_of_the_token`; `test::admin_handover_in_two_steps_still_works`; `test_governance::an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts`. A token without an admin could never be unpaused or rotate its manager; hand over by proposing and applying an `Admin` change (`transfer_admin_role` during setup), then the nominee calls `accept_admin_transfer`. | diff --git a/docs/COMPLIANCE_MATRIX.md b/docs/COMPLIANCE_MATRIX.md index 02e8cd9..2be50f7 100644 --- a/docs/COMPLIANCE_MATRIX.md +++ b/docs/COMPLIANCE_MATRIX.md @@ -21,7 +21,7 @@ Every investor account in the vault ecosystem exists in one of three primary com 3. **Frozen (`Frozen`)**: - The account is subject to sanctions, legal freezing, or security hold (`is_frozen(account) == true` via `ShareToken::set_address_frozen`). - The share token refuses a `transfer` from or to a frozen address. Minting does not check freezes, and `forced_transfer` ignores them. - - What the vault owes a frozen investor is **held**: not paid, not returned, not moved. Priced cash stays in `committed` and a wind-down slice stays in `wind_down_owed`, where no other holder can take it. Only an unfreeze releases it. A partial freeze limits exits to the free balance. + - What the vault owes a frozen investor is **held**: not paid, not returned, not moved. Priced cash stays in `committed` and a wind-down slice stays in `wind_down_owed`, where no other holder can take it. Only an unfreeze releases it. A legal-order transfer (`legal_transfer`), which compliance and governance sign together, can move shares in the investor's own wallet, but not value the vault holds for them: deposit escrow, priced but unminted shares, shares escrowed in a redemption, priced cash, or a wind-down slice. A partial freeze limits exits to the free balance. This follows how asset-freeze and blocking rules generally treat frozen property: held, not released, not returned to the owner and not confiscated, with the entitlement kept and recorded. It is not legal advice; counsel must confirm it for each jurisdiction. @@ -39,6 +39,7 @@ Every investor account in the vault ecosystem exists in one of three primary com | **`claim_redeem`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); the cash stays in `committed` | **Exit-Only Cash Path**: the settlement asset pays out from the liquid reserve and the escrowed shares are burned from the vault. The cash claim checks `is_frozen` on the share token first; it does not check the allowlist. A partial freeze is not checked. | | **`claim_wind_down`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); the slice stays in `wind_down_owed` | **Wind-Down Payout**: pays the holder's pro-rata entitlement and burns the free shares it surrenders. The vault checks `is_frozen` and `get_frozen_tokens` on the share token first; a partial freeze surrenders only the free shares — the rest keeps earning every round and is paid once unfrozen. | | **`transfer`** (Secondary) | ✅ Allowed | ❌ **Refused** | ❌ **Refused** | Standard peer-to-peer share transfer via SEP-57. Requires both sender and receiver to be allowlisted and neither to be frozen. | +| **`legal_transfer`** (compliance + governance) | ✅ Allowed | ✅ Allowed | ✅ Allowed | Moves shares in the holder's wallet under a legal order; the destination must pass `verify_identity`; never into or out of the vault. It cannot reach value the vault holds for a frozen holder; only an unfreeze releases that. | --- @@ -92,6 +93,7 @@ The share token is OpenZeppelin's RWA token. The allowlist and freeze checks sit | `transfer` | Refuses if sender or receiver is frozen; the sender needs enough unfrozen balance | Sender and receiver | `can_transfer` before, `transferred` after | | `mint` | None | Receiver | `can_create` before, `created` after | | `forced_transfer` | None; releases as much of a partial freeze as the amount needs | None | `transferred` after | +| `legal_transfer` (the kit's own, built on `forced_transfer`) | None; releases as much of a partial freeze as the amount needs | Receiver | `transferred` after | | `burn` | None; releases as much of a partial freeze as the amount needs | None | `destroyed` after | 0.7.2 has five compliance hooks: `can_transfer` and `can_create` are asked before the state change, and `transferred`, `created` and `destroyed` are told after it. There is no `can_destroy`. @@ -126,3 +128,4 @@ The behavior specified in this matrix is verified by on-chain automated test sui * `a_frozen_holders_slice_stays_owed_until_the_freeze_lifts` ([`test/wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs)): Verifies a frozen holder's wind-down slice stays in `wind_down_owed` until the freeze lifts. * `a_frozen_holders_escrowed_exit_waits_through_a_wind_down`: Verifies a frozen holder's escrowed redemption and wind-down claim are both held through a wind-down. * `a_partial_freeze_keeps_frozen_shares_out_of_a_wind_down_claim`: Verifies a partial freeze excludes frozen shares from a wind-down surrender. +* `legal_transfer::*` ([`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs)): Verifies a legal-order transfer needs compliance and governance as two different accounts, never moves shares out of or into the vault's address, lands only on an allowlisted account and publishes its order reference; recovery needs the same co-signature, and the vault's address cannot be frozen. diff --git a/docs/TESTING.md b/docs/TESTING.md index 8c0709e..c10d67e 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -78,6 +78,7 @@ nothing. | Guardian, governance and upgrade powers stay with their role | [`contracts/async-vault/src/test/controls.rs`](../contracts/async-vault/src/test/controls.rs), [`contracts/async-vault/src/test/upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs), [`contracts/async-vault/src/test/wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs) | `the_guardian_pauses_but_only_governance_unpauses`, `only_governance_proposes_and_cancels`, `only_governance_applies`, `only_governance_sets_the_delay` | | Roles that must stay separate are refused at deployment | [`contracts/async-vault/src/test/constructor.rs`](../contracts/async-vault/src/test/constructor.rs) | `the_treasury_may_not_also_be_the_guardian`, `compliance_may_not_also_be_governance` | | Share token: only the right role mints, burns, pauses, freezes or moves shares | [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs) | `unauthorized_caller_cannot_mint`, `unauthorized_caller_cannot_burn`, `unauthorized_caller_cannot_forced_transfer`, `unauthorized_caller_cannot_freeze_or_recover` | +| Share token: compliance moves a holder's shares only with the admin's signature in the same call, never out of or into the vault's address, and cannot freeze the vault | [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs) | `compliance_alone_cannot_move_shares`, `one_account_cannot_co_sign_with_itself`, `nothing_leaves_the_vault_address`, `recovery_needs_the_admin_too`, `the_vault_address_cannot_be_frozen` | | Share token: only the admin binds the vault, finishes setup and runs the timelock | [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs) | `only_the_admin_binds_and_finishes`, `only_the_admin_proposes_cancels_and_applies` | | Oracle: only the attester posts, only the guardian pauses the NAV | [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | `attest_is_role_gated`, `pausing_the_nav_is_limited_to_the_guardian`, `only_admin_can_set_config` | | Oracle: only the admin binds the vault, finishes setup, runs the timelock and revokes a role | [`contracts/nav-oracle/src/test_governance.rs`](../contracts/nav-oracle/src/test_governance.rs) | `only_the_admin_binds_and_finishes`, `only_the_admin_proposes_cancels_and_applies`, `only_the_admin_revokes_a_role` | @@ -124,7 +125,7 @@ fixture wires them as the deploy does and finishes the vault's setup; | The vault mints and burns shares through its role on the share token | [`contracts/async-vault/src/test/constructor.rs`](../contracts/async-vault/src/test/constructor.rs), [`contracts/async-vault/src/test/redeem.rs`](../contracts/async-vault/src/test/redeem.rs) | `the_vault_holds_the_manager_role_on_the_share_token`, `claim_redeem_pays_assets_and_burns_the_shares` | | The vault checks the identity verifier at request; the share token checks it on mint and transfer | [`contracts/async-vault/src/test/compliance.rs`](../contracts/async-vault/src/test/compliance.rs), [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs) | `an_investor_who_was_never_allowlisted_is_refused_before_any_cash_moves`, `a_frozen_or_delisted_holder_cannot_transfer_shares`, `manager_mints_to_allowlisted_account` | | The vault reads the oracle and refuses a stale or paused feed | [`contracts/async-vault/src/test/oracle_pricing.rs`](../contracts/async-vault/src/test/oracle_pricing.rs) | `the_epoch_is_priced_at_the_attestation`, `fulfill_is_rejected_once_the_feed_goes_stale`, `a_paused_feed_is_refused_by_the_vault_not_the_oracle` | -| The vault finishes setup only once the token and the oracle report it with their own setup closed, it is the token's only `manager`, the token has no shares outstanding, it passes the identity check and the token is bound to compliance; before that it takes no deposit | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs) | `every_wiring_step_is_checked_before_setup_finishes`, `a_deposit_before_setup_finishes_is_refused`, `setup_cannot_finish_while_a_hand_over_is_pending` | +| 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 and the token is bound to compliance; before that it takes no deposit | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs) | `every_wiring_step_is_checked_before_setup_finishes`, `a_deposit_before_setup_finishes_is_refused`, `setup_cannot_finish_while_a_hand_over_is_pending` | | Upgrading replaces the vault's code and migrates once | [`contracts/async-vault/src/test/upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs) (needs `upgrade_wasm`) | `applying_a_wasm_change_replaces_the_code`, `a_migration_refuses_a_second_run` | | The share token upgrades through the vault's timelock under live balances, an open deposit and an open redemption, and migrates once; called directly, `upgrade` does not exist | [`contracts/async-vault/src/test/upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs) (needs `upgrade_wasm`) | `the_token_upgrades_under_live_balances_and_open_requests`, `the_token_exposes_no_immediate_upgrade` | | The oracle upgrades through the vault's timelock under a live valuation, keeps its config, roles and vault, and migrates once; called directly, `upgrade` does not exist | [`contracts/nav-oracle/src/test_governance.rs`](../contracts/nav-oracle/src/test_governance.rs) (needs `upgrade_wasm`) | `the_oracle_upgrades_under_a_live_valuation`, `the_oracle_exposes_no_immediate_upgrade` | diff --git a/docs/kit-spec.md b/docs/kit-spec.md index b7e46b9..043c490 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -85,7 +85,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - **Testable Property**: - Upgrade delay $\in [7 \text{ days}, 90 \text{ days}]$ and $\text{delay} \ge \text{notice}$, checked when a delay change is proposed and again when it applies. - Wind-down delay $\in [7 \text{ days}, 90 \text{ days}]$ (`WindDownDelayTooShort`, `WindDownDelayTooLong`); 7 days when none was set. - - The vault takes no deposit until its one-time setup closes (`SetupOpen`). `finish_setup` refuses with `SetupIncomplete` until the share token and the oracle report this vault with their own setup closed, the vault is the token's only `manager`, the token has no shares outstanding, the vault passes the token's identity check, and the token is bound to its compliance contract. Shares minted before setup closes would redeem against depositors' money. It refuses with `AdminTransferPending` while an admin hand-over offered during setup is pending. Setup never reopens (`SetupClosed`). + - The vault takes no deposit until its one-time setup closes (`SetupOpen`). `finish_setup` refuses with `SetupIncomplete` until the share token and the oracle report this vault with their own setup closed, the vault is the token's only `manager`, the token has no shares outstanding, the vault passes the token's identity check, the token has not frozen the vault's address, and the token is bound to its compliance contract. Shares minted before setup closes would redeem against depositors' money. It refuses with `AdminTransferPending` while an admin hand-over offered during setup is pending. Setup never reopens (`SetupClosed`). - After setup, the vault's custodian, role grants, revoking `manager`, admin hand-overs and lowering the notice or the wind-down delay go through the queue; a direct call is refused with `UseTimelock`. Raising the notice or the wind-down delay, the deposit cap, `pause`/`unpause` and revoking any other role stay instant. A `Notice` or `WindDownDelay` change is checked against its bounds when proposed and again when it applies. - Every change waits in the queue (`propose_change` → `apply_change`). Applying before its eta reverts with `ChangeNotReady`. - A change whose delay is shorter than the notice standing at apply time reverts with `UpgradeDelayBelowNotice`. @@ -120,7 +120,7 @@ Deploying the Strata Vault Kit involves five contracts configured via `environme | **`share_token`** | `name` | `String` | Human-readable token name (e.g. `"Strata Vault USDC"`) | `share_token.constructor_args` | | **`share_token`** | `symbol` | `String` | Token ticker symbol (e.g. `"bvUSDC"`) | `share_token.constructor_args` | | **`share_token`** | `admin` | `Address` | Token administrative multisig (Governance) | `share_token.constructor_args` | -| **`share_token`** | `compliance_authority` | `Address` | Compliance multisig; receives the `compliance` role for freezes, forced transfers and recovery. The vault's `manager` role is granted after deploy | `share_token.constructor_args` | +| **`share_token`** | `compliance_authority` | `Address` | Compliance multisig; receives the `compliance` role for freezes, plus legal transfers and recovery with governance's co-signature. The vault's `manager` role is granted after deploy | `share_token.constructor_args` | | **`share_token`** | `compliance` | `Address` | Address of deployed `compliance` contract | `share_token.constructor_args` | | **`share_token`** | `identity_verifier` | `Address` | Address of deployed `identity_verifier` contract | `share_token.constructor_args` | | **`nav_oracle`** | `admin` | `Address` | Oracle admin multisig (Governance) | `nav_oracle.constructor_args` | @@ -174,6 +174,7 @@ The `async_vault` constructor strictly verifies pairwise separation across autho | `oracle_bound` | The oracle names this vault through `vault()` and its own setup is finished. | | `sole_token_manager` | This vault is the share token's only `manager`. | | `allowlisted` | This vault passes the share token's identity verifier, so it can hold escrowed shares. A delisted holder's deposit converts to an exit by minting the shares to the vault's own address, so that conversion needs this flag true. | +| `vault_unfrozen` | The share token has not frozen this vault's address. A frozen vault could not hand escrowed shares back when a holder cancels a redemption. | | `compliance_bound` | The share token is in its compliance contract's `linked_tokens`. | | `feed_valid` | The oracle reports `Valid`: a report exists, it has not expired and the NAV is not paused. | | `freshness_covers_notice` | The oracle's `freshness_duration` is at least the vault's notice. | @@ -184,17 +185,17 @@ The `async_vault` constructor strictly verifies pairwise separation across autho ## 3. Role & Authority Matrix -Strata distributes control across five native Stellar multisig authorities and permissionless public entrypoints. Every privileged function is mapped to exactly one authority. +Strata distributes control across five native Stellar multisig authorities and permissionless public entrypoints. Every privileged function is mapped to exactly one authority, except the share token's `legal_transfer` and `recover_balance`, which need compliance and governance in the same call. ### Authority Capabilities & Anti-Capabilities | Authority | Quorum / Profile | Authorized Capabilities | Anti-Capabilities (What It Cannot Do) | |---|---|---|---| -| **Governance** | High-quorum deliberative multisig | Set parameters (`notice`, `custodian`, `deposit_cap`, `wind_down_delay`), propose/apply upgrades, propose/cancel wind-down, unpause the vault, pause/unpause the share token, transfer admin, finish each contract's one-time setup. After setup, these wait for the timelock: role grants, revoking the manager, admin hand-over, the custodian, lowering the notice or the wind-down delay, upgrades, the token's compliance and verifier swaps, and oracle config changes other than a shorter freshness window. Revoking the treasury or the guardian stays instant, because taking a power away moves no value. | **Cannot pause the vault directly**; cannot move funds; cannot initiate custodian transfers; cannot attest prices; cannot bypass timelocks. | +| **Governance** | High-quorum deliberative multisig | Set parameters (`notice`, `custodian`, `deposit_cap`, `wind_down_delay`), propose/apply upgrades, propose/cancel wind-down, unpause the vault, pause/unpause the share token, transfer admin, finish each contract's one-time setup, co-sign compliance's `legal_transfer` and `recover_balance` as the share token's admin. After setup, these wait for the timelock: role grants, revoking the manager, admin hand-over, the custodian, lowering the notice or the wind-down delay, upgrades, the token's compliance and verifier swaps, and oracle config changes other than a shorter freshness window. Revoking the treasury or the guardian stays instant, because taking a power away moves no value. | **Cannot pause the vault directly**; cannot move funds; cannot initiate custodian transfers; cannot attest prices; cannot bypass timelocks. | | **Guardian** | Low-threshold fast-reaction multisig | `pause` (vault); `pause_nav` (oracle emergency price freeze). | **Cannot unpause**; cannot move funds; cannot block redemption requests; cannot block payable claims or cancellations. | | **Treasury** | Operational capital multisig | `deploy_to_custodian` (moves free reserve); `fund` (returns assets from custodian). | **Cannot deploy while anything is uncovered**; cannot touch escrowed subscription assets; cannot alter parameters or pause. | | **Attester** | High-security attestation multisig | `attest` (submits NAV report with proof reference). | **Cannot price outside deviation band**; cannot violate cooldown; cannot operate on the vault directly. | -| **Compliance** | Legal / KYC operator multisig | Manage allowlist (`allow`), topic claims; token interventions (`freeze`, `forced_transfer`, `recovery`). | **Cannot touch vault reserve**; cannot pause vault operations; cannot bypass exit-only cash redemptions for an unfrozen holder — freezing holds a covered claim until it lifts, rather than paying or redirecting it. | +| **Compliance** | Legal / KYC operator multisig | Manage allowlist (`allow`), topic claims; token interventions (`freeze`; `legal_transfer` and `recovery` co-signed by governance). | **Cannot touch vault reserve**; cannot move a holder's shares without governance's signature in the same call; cannot move shares out of or into the vault's address, or freeze it once the token knows its vault (an earlier freeze makes the vault's `finish_setup` refuse); cannot pause vault operations; cannot bypass exit-only cash redemptions for an unfrozen holder — freezing holds a covered claim until it lifts, rather than paying or redirecting it. | | **Manager** | Operational keeper account / vault | `close_epoch` (vault); `mint`, `burn`, `forced_transfer` (share token). | **Cannot choose prices**; cannot prevent epoch fulfillment; cannot alter timelocks. | | **Permissionless** | Any caller / keeper / investor | `fulfill_epoch`, `request_deposit`, `cancel_deposit`, `claim_deposit`, `request_redeem`, `cancel_redeem`, `claim_redeem`, `activate_wind_down`, `finalize_wind_down_round`, `claim_wind_down`. | Operations are strictly constrained by contract state, timing, and solvency guards. | @@ -253,8 +254,10 @@ Strata distributes control across five native Stellar multisig authorities and p | **`ShareToken`** | `unpause` | Admin (Governance) | `#[only_admin]`, `caller.require_auth()` | N/A | | **`ShareToken`** | `mint` | Manager (Vault) | `#[only_role(operator, "manager")]` | Bypasses token pause | | **`ShareToken`** | `burn` | Manager (Vault) | `#[only_role(operator, "manager")]` | Bypasses token pause | -| **`ShareToken`** | `forced_transfer` | Manager (Vault) or Compliance | `#[only_any_role(operator, ["manager", "compliance"])]` | Bypasses token pause | -| **`ShareToken`** | `set_address_frozen`, `freeze_partial_tokens`, `unfreeze_partial_tokens`, `recover_balance` | Compliance | `#[only_role(operator, "compliance")]` | No | +| **`ShareToken`** | `forced_transfer` | Manager (Vault) | `#[only_role(operator, "manager")]` | Bypasses token pause | +| **`ShareToken`** | `legal_transfer` | Compliance, co-signed by Governance (token admin) | `#[only_role(operator, "compliance")]` and the admin's `require_auth()` in the same call, from two different accounts; never from or to the vault's address; the destination passes `verify_identity`; a reference of 1–128 bytes | Bypasses token pause | +| **`ShareToken`** | `set_address_frozen`, `freeze_partial_tokens`, `unfreeze_partial_tokens` | Compliance | `#[only_role(operator, "compliance")]`; never freezes the vault's address once the token knows its vault | No | +| **`ShareToken`** | `recover_balance` | Compliance, co-signed by Governance (token admin) | `#[only_role(operator, "compliance")]` and the admin's `require_auth()` in the same call, from two different accounts; never from or to the vault's address | No | | **`ShareToken`** | `set_compliance`, `set_identity_verifier` | Governance | `#[only_admin]`; instant during setup, then `propose_change` | No | | **`ShareToken`** | `bind_vault` | Governance (token admin) | `#[only_admin]`; during setup, once | No | | **`ShareToken`** | `finish_setup` | Governance | `#[only_admin]`; needs a bound vault and no pending admin hand-over | No | @@ -283,7 +286,7 @@ A clean deployment executes in three sequenced phases: - Call `Compliance::bind_token(share_token_address)` to link SEP-57 rule verification. - Call `NavOracle::bind_vault(vault_address)`, then `NavOracle::finish_setup()`. The oracle's admin changes are instant until then and wait for the vault's timelock afterwards. - On the vault, call `set_custodian(custodian)`, `set_notice(secs)` and, if initial capital intake is bounded, `set_deposit_cap(cap)`. All three are instant during setup, so deposits never open without the cap. - - Then call `finish_setup()` on the vault. It refuses with `SetupIncomplete` until every wiring step above is in place and the token has no shares outstanding, and with `AdminTransferPending` while an admin hand-over offered during setup is pending. The vault takes no deposit before it. + - Then call `finish_setup()` on the vault. It refuses with `SetupIncomplete` until every wiring step above is in place, the token has no shares outstanding and the vault's address is not frozen on it, and with `AdminTransferPending` while an admin hand-over offered during setup is pending. The vault takes no deposit before it. 3. **Phase 3: Operational Parameterization** - Oracle reporter submits the genesis NAV attestation (`attest`). @@ -299,7 +302,7 @@ instance is archived refuses every call. |---|---|---|---| | `async_vault` | Its instance, and every persistent entry it reads or writes: epochs, requests, settlements, wind-down positions, pending governance changes | Every call that reads or writes the entry | 30 days (518,400 ledgers), once less than 29 days remain | | `nav_oracle` | Its instance, the latest report, and each pending governance change it reads or writes | `attest` extends the instance and the report; `bind_vault`, `finish_setup`, `grant_role`, `transfer_admin_role`, `propose_change`, `cancel_change` and `apply_change` extend the instance; every other call extends whichever entry it reads or writes | Same | -| `share_token` | Its instance, and each pending governance change it reads or writes | Every call that moves shares or changes token settings: `transfer`, `transfer_from`, `approve`, `mint`, `burn`, `forced_transfer`, freezes, recovery, `set_compliance`, `set_identity_verifier`, `pause`, `unpause`, `grant_role`, `revoke_role`, `transfer_admin_role`, `bind_vault`, `finish_setup`, `propose_change`, `cancel_change`, `apply_change` | Same | +| `share_token` | Its instance, and each pending governance change it reads or writes | Every call that moves shares or changes token settings: `transfer`, `transfer_from`, `approve`, `mint`, `burn`, `forced_transfer`, `legal_transfer`, freezes, recovery, `set_compliance`, `set_identity_verifier`, `pause`, `unpause`, `grant_role`, `revoke_role`, `transfer_admin_role`, `bind_vault`, `finish_setup`, `propose_change`, `cancel_change`, `apply_change` | Same | | `identity_verifier` | Its instance and each allowlist entry | `allow`, `is_allowed`, `verify_identity` | Same | | `compliance` | Its instance | Every hook the token calls (`can_create`, `can_transfer`, `created`, `transferred`, `destroyed`) and its admin calls | Same | diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index 014ff84..8228d95 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -79,7 +79,7 @@ or legal solution, and not a vault for on-chain RWA tokens. | Valuation reporter | attestation | Attests the share price with a proof reference, under on-chain guardrails | | Treasury ops | treasury | Moves free reserve between the vault and the custodian | | Epoch sealer | manager (vault role, not an authority) | Seals the open epoch (`close_epoch`), nothing else; a vault role with its own key at deploy, meant to become permissionless (§8.4) | -| Compliance | compliance | Maintains the allowlist; token interventions (freeze, forced transfer, recovery) | +| Compliance | compliance | Maintains the allowlist; token interventions (freeze; legal-order transfer and recovery, each co-signed by governance) | | Guardian | guardian | Pauses the vault, which stops deposit requests, epoch pricing and upgrade application and freezes the upgrade timelock; can never block redemption requests, payable claims, or custodian deployments | | Governance | governance | Parameters, roles, custodian rotation, upgrades, wind-down proposal and proposal cancellation; once each contract's one-time setup closes, the changes that can move value or shorten an exit wait for the timelock | | Custodian | Not an authority | Off-chain party holding the real-world structure; a genesis-configured slot rotatable only by governance | @@ -167,8 +167,9 @@ For the exhaustive access control matrix, dual `manager` role distinction, and e and never identity-gated. A delisted, non-frozen investor exits through the exit-only cash path. A frozen investor's claims are held, not paid, until the freeze is lifted ([`COMPLIANCE_MATRIX.md`](./COMPLIANCE_MATRIX.md) §1). - Outside this guarantee, disclosed as trust assumptions: deposit-asset - issuer controls and reserve liquidity. + A legal-order transfer moves only shares in the investor's wallet, never + value the vault holds. Outside this guarantee, disclosed as trust + assumptions: deposit-asset issuer controls and reserve liquidity. 6. **Split accounting, with priced and payable as separate states.** The vault exposes committed (priced redemption liabilities), cancellable escrow @@ -286,8 +287,8 @@ number with a proof reference. | Component | Role | Controls | | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Vault** | Request lifecycle, epoch sealing by its manager role, pricing, split reserve accounting, custodian transfers, upgrade control | One authority per privileged entrypoint; upgrades behind a governance timelock no shorter than the notice, bounded between 7 and 90 days. 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 (admin-gated; governance by deploy) | Manager role held only by the vault, for mint, burn and escrow; compliance role held by the compliance authority, for freeze, forced transfer and recovery; swapping the compliance or identity-verifier contract needs the admin and, after a one-time setup, waits for the vault's timelock | -| **Token roles** | Access-control roles on the share token, checked directly via native access control: `manager` mints, burns and escrows; `compliance` freezes, forces transfers and recovers | Granted and revoked by the token admin (governance by deploy). After a one-time setup, a grant, revoking `manager` and an admin handover wait for the vault's timelock; revoking `compliance` stays instant | +| **Share token** | OZ SEP-41 + SEP-57 RWA extensions: freeze, forced transfer, recovery, identity and compliance checks on every transfer, independent transfer pause (admin-gated; governance by deploy) | Manager role held only by the vault, for mint, burn and escrow; compliance role held by the compliance authority, for 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 | +| **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 | Managed by the compliance authority via direct role access; replaceable by OZ's identity verifier and compliance contracts (with RWA Wizard modules) without touching the token | | **Authorities** | governance (parameters, roles, timelocked upgrades), compliance (allowlist, token interventions), attestation (valuation only), treasury (reserve movements only), guardian (pause; never payable claims) | Each contract grants its own roles; the oracle's, share token's and verifier's admins and the oracle's attester and guardian are constructor parameters independent of the vault's roles, equal to governance, compliance and guardian by deploy only; treasury and guardian distinct, treasury and governance distinct, compliance distinct from governance and treasury; governance and guardian may coincide; the vault's manager role is unconstrained | | **Custodian** | Off-chain party holding the real-world structure; a genesis-configured slot rotatable only by governance | Not an on-chain authority | @@ -416,9 +417,13 @@ interface today. replaces it with OZ's contracts without touching the token. - KYC happens wherever the operator runs it; the chain sees only its output. The compliance authority writes allowlist entries on the identity verifier. -- Token interventions (freeze, unfreeze, forced transfer, recovery) are - compliance operations through the share token's `compliance` role, available even while the vault is - paused. +- Token interventions are compliance operations through the share token's + `compliance` role, available even while the vault is paused. Freezing and + unfreezing are compliance's alone; moving shares under a legal order + (`legal_transfer`) and recovering a lost wallet (`recover_balance`) also need + governance's signature in the same call. Neither moves shares out of or into + the vault's address, and that address cannot be frozen once the token knows + its vault; an earlier freeze makes the vault's `finish_setup` refuse. - The complete specification of investor states (Allowlisted, De-listed, Frozen) and the exit-only cash path across all vault lifecycle operations is detailed in [`docs/COMPLIANCE_MATRIX.md`](./COMPLIANCE_MATRIX.md). @@ -483,9 +488,10 @@ investor sees the exit-only path. ### Admin panel -Operates an existing vault; deploys nothing. Each privileged entrypoint is gated -on one role of one contract, except the share token's `forced_transfer`, which -accepts its `manager` (the vault) or `compliance` role. The oracle's NAV pause has +Operates an existing vault; deploys nothing. Each privileged entrypoint needs +one role of one contract, except the share token's `legal_transfer` and +`recover_balance`, which need its `compliance` role and its admin in the same +call. The oracle's NAV pause has two entrypoints: `pause_nav` for its guardian and `set_nav_paused` for its admin. The panel groups the entrypoints into five domain surfaces. A surface is visible to the authorities that act in it, and each action only to its own @@ -494,7 +500,7 @@ visible to the authorities that act in it, and each action only to its own | Surface | Caller | Cadence | Operations | | ------------- | ----------------------------------------------------------------------------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Cycle | manager, attestation, treasury; anyone prices, funds, activates and finalizes | Continuous | Epoch sealing (manager); attestations; transfers to the custodian (treasury); pricing, funding, wind-down activation and round finalization (permissionless) | -| Compliance | compliance; verifier admin (compliance by deploy) | Continuous | Allowlist (verifier admin); freeze/unfreeze of an address or of part of its shares, forced transfer (compliance); recovery is not offered, the kit's verifier names no recovery target | +| Compliance | compliance; verifier admin (compliance by deploy); share-token admin (governance by deploy) as co-signer | Continuous | Allowlist (verifier admin); freeze/unfreeze of an address or of part of its shares (compliance); legal-order transfer (compliance and share-token admin); recovery is not offered, the kit's verifier names no target | | Emergency | guardian, governance; oracle admin and share-token admin (both governance by deploy) | Rare and urgent | Vault pause (guardian) and unpause (governance); oracle NAV pause (oracle guardian, or oracle admin through `set_nav_paused`), reset and `clear_latest` (oracle admin); share-token pause/unpause (share-token admin) | | Configuration | governance; oracle admin and share-token admin (both governance by deploy) | Rare and deliberate | Custodian slot, deposit cap, notice and wind-down delay (governance); oracle guardrails (oracle admin); compliance and identity-verifier contracts (share-token admin). After setup, the vault's custodian and a lower notice or wind-down delay wait for the timelock, as do the oracle and token changes listed in §8.9; raising the notice or the delay and the deposit cap stay instant | | Governance | governance; oracle admin and share-token admin for their own roles and handover (both governance by deploy) | Very rare | Roles and admin handover on the vault, oracle and share token; upgrade and its delay, wind-down proposal and cancellation (governance). After setup, every grant, `manager` revoke, handover and upgrade waits for the timelock; revoking an operational role (treasury, guardian, attester, compliance) stays instant | @@ -537,7 +543,8 @@ flowchart LR TPAUSE[pause · unpause] TCFG[set_compliance ·
set_identity_verifier] TGOV[roles · admin handover] - TCMP[freeze · unfreeze ·
partial freeze · forced_transfer] + TCMP[freeze · unfreeze ·
partial freeze] + TLEG[legal_transfer] end subgraph VERIFIER["Identity verifier"] @@ -568,6 +575,8 @@ flowchart LR OADM --> OGOV --> GVN TADM --> TGOV --> GVN CMP --> TCMP --> CPL + CMP --> TLEG --> CPL + TADM -->|co-signs| TLEG VADM --> VALLOW --> CPL GOV -.->|by deploy| OADM GOV -.->|by deploy| TADM @@ -589,12 +598,12 @@ public surface given the vault address. | Boundary | Risk | Mitigation | | ---------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Attestation authority | Wrong or compromised reports misprice requests | Multisig reporter; the deviation cap bounds a single report in both directions, and a downward cap of 100% leaves `min_answer` as the only floor; the cooldown bounds frequency; a sustained sequence of biased reports within the cap remains possible, is bounded in speed, and is the monitoring plan's primary alert, with the guardian pause as the reactive control. Residual risk: value transfer between entry and exit cohorts | -| Governance keys | Malicious upgrade | Timelock on every code change and on the delay itself, bounded by a minimum no shorter than the standing notice and a maximum ceiling (90 days). A pause freezes the timelock clock and pushes the ETA by the duration paused, guaranteeing that investors have an unpaused exit window before any code change applies; applying while paused is refused; nothing is proposed or applied while the vault winds down. The vault's own custodian, role grants, `manager` revocations, admin handovers and lowering of the notice or the wind-down delay wait on that clock once its one-time setup closes, and it takes no deposit before then; revoking its treasury or guardian stays instant. Setup closes only once the token and the oracle run on the vault's clock with their own setup closed, the vault is the token's only minter, no shares exist yet (shares minted earlier would redeem against depositors' money), the vault passes the identity check and the token is bound to its compliance contract. The share token's upgrades, grants, `manager` revocations, admin handovers and compliance or verifier swaps wait on the same clock once its setup closes. So do the oracle's upgrades, grants, admin handovers and every guardrail change except a shorter freshness window; its NAV pause, `clear_latest` and revoking its attester or guardian stay instant. | +| Governance keys | Malicious upgrade | Timelock on every code change and on the delay itself, bounded by a minimum no shorter than the standing notice and a maximum ceiling (90 days). A pause freezes the timelock clock and pushes the ETA by the duration paused, guaranteeing that investors have an unpaused exit window before any code change applies; applying while paused is refused; nothing is proposed or applied while the vault winds down. The vault's own custodian, role grants, `manager` revocations, admin handovers and lowering of the notice or the wind-down delay wait on that clock once its one-time setup closes, and it takes no deposit before then; revoking its treasury or guardian stays instant. Setup closes only once the token and the oracle run on the vault's clock with their own setup closed, the vault is the token's only minter, no shares exist yet (shares minted earlier would redeem against depositors' money), the vault passes the identity check, its address is not frozen on the token and the token is bound to its compliance contract. The share token's upgrades, grants, `manager` revocations, admin handovers and compliance or verifier swaps wait on the same clock once its setup closes. So do the oracle's upgrades, grants, admin handovers and every guardrail change except a shorter freshness window; its NAV pause, `clear_latest` and revoking its attester or guardian stay instant. | | 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 | | Share-token pause | Cancellations and secondary transfers halted | Admin-gated (governance by deploy; the token has no guardian role); it refuses SEP-41 transfers and escrowed-share returns only, never pricing, claims, mint, burn or forced transfer; only the token admin lifts it (§8.5) | -| Compliance keys | Wrongful delisting or freeze | Delisted investors keep the exit-only cash path; freezes require the share token's compliance role and are auditable per operation | +| Compliance keys | Wrongful delisting, freeze or transfer | Delisted investors keep the exit-only cash path; freezes require the share token's compliance role and are auditable per operation; moving a holder's shares also needs governance's signature in the same call, lands only on an allowlisted account and never moves shares out of or into the vault's address, which cannot be frozen once the token knows its vault | | Compliance module | Faulty module blocks transfers | Fail-closed semantics; replaceable by governance without touching the token | | Deposit asset issuer | Freeze or clawback of the vault's reserve | Not mitigated by the kit; declared risk of the chosen asset, verified and reported at genesis (auth flags) | | Custodian / real world | Underlying loss or delay | Reflected through attested NAV; the kit constrains what reaches the chain, it does not verify the world | diff --git a/scripts/harness/deploy.ts b/scripts/harness/deploy.ts index 0b1492c..5398ad1 100644 --- a/scripts/harness/deploy.ts +++ b/scripts/harness/deploy.ts @@ -248,6 +248,7 @@ async function finishVaultSetup( "oracle_bound", "sole_token_manager", "allowlisted", + "vault_unfrozen", "compliance_bound", ] as const const broken = wiring.filter((flag) => !health[flag])