diff --git a/AGENTS.md b/AGENTS.md index 975b3d1c..193f9d71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -143,7 +143,10 @@ contracts. The addresses emitted by the deploy script are authoritative. one-line summary, then `# Arguments` with units (asset base units, share base units, WAD prices, seconds or ledgers), `# Errors`, `# Events` and `# Authorization`. Every error variant's doc says when it happens. Keep them - short: they are stored in the Wasm as part of the contract spec. + short: they are stored in the Wasm as part of the contract spec, which keeps + 1,024 bytes of each doc and drops the rest without a warning. + `every_contract_doc_fits_the_spec` (`crates/strata-bindings/tests/spec_docs.rs`) + fails when one is longer. - **Design notes.** Each contract's `lib.rs` opens with a `//! # Design` section: five to ten bullets, each a choice and its trade-off, in plain words and with no issue numbers or decision ids. diff --git a/contracts/async-vault/src/error.rs b/contracts/async-vault/src/error.rs index c20c3903..8f22f895 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -139,7 +139,9 @@ strata_bindings::error_enum! { /// shares outstanding, so setup cannot finish. SetupIncomplete = 6067, /// The holder fails the share token's identity check, so the vault takes no - /// deposit request from them and returns no escrowed shares to them. + /// deposit request from them and returns no escrowed shares to them. A + /// verifier that cannot answer, such as a broken or archived one, counts + /// as a failed check. NotAllowlisted = 6068, /// The wind-down delay is below the shortest the vault accepts. WindDownDelayTooShort = 6069, diff --git a/contracts/async-vault/src/governance.rs b/contracts/async-vault/src/governance.rs index f0904ced..1accb496 100644 --- a/contracts/async-vault/src/governance.rs +++ b/contracts/async-vault/src/governance.rs @@ -217,7 +217,7 @@ pub(crate) fn set_notice(e: &Env, secs: u64) { pub(crate) fn propose(e: &Env, change: VaultChange) -> u32 { guards::not_winding_down(e); guards::setup_finished(e); - guards::change_in_bounds(e, &change); + guards::valid_change(e, &change); guards::not_a_second_code_change(e, &change); let id = queue::propose::(e, &change).unwrap_or_else(|r| refuse(e, r)); ChangeProposed { @@ -238,7 +238,7 @@ pub(crate) fn apply(e: &Env, id: u32) { let change: VaultChange = queue::take_ready::(e, id).unwrap_or_else(|r| refuse(e, r)); // The notice or the delay may have moved since the proposal; this check decides. - guards::change_in_bounds(e, &change); + guards::valid_change(e, &change); let admin = access_control::get_admin(e) .unwrap_or_else(|| panic_with_error!(e, VaultError::AdminRequired)); match &change { diff --git a/contracts/async-vault/src/guards.rs b/contracts/async-vault/src/guards.rs index 2eab6ec7..577eb4c9 100644 --- a/contracts/async-vault/src/guards.rs +++ b/contracts/async-vault/src/guards.rs @@ -135,7 +135,8 @@ pub(crate) fn wind_down_delay_in_bounds(e: &Env, secs: u64) { } } -/// An exit takes a full notice, so a delay shorter than one is not a window. +/// A shorter delay than the notice would let a change land before an epoch +/// sealed at its proposal could be priced. pub(crate) fn upgrade_delay_in_bounds(e: &Env, secs: u64) { if secs < MIN_UPGRADE_DELAY { panic_with_error!(e, VaultError::UpgradeDelayTooShort); @@ -155,20 +156,21 @@ pub(crate) fn custodian_not_the_vault(e: &Env, custodian: &Address) { } /// A feed that cannot answer would stall every epoch after the swap. -pub(crate) fn feed_bound_here(e: &Env, feed: &Address) { +pub(crate) fn feed_bound_here_and_answering(e: &Env, feed: &Address) { if !governance::bound_here(e, feed) || !governance::answers_as_feed(e, feed) { panic_with_error!(e, VaultError::FeedNotBound); } } -/// The bounds a change must meet when it is proposed and again when it -/// applies. -pub(crate) fn change_in_bounds(e: &Env, change: &VaultChange) { +/// What a change must meet when it is proposed and again when it applies: a +/// delay or notice within its bounds, a feed bound here that answers, or a +/// custodian other than the vault. +pub(crate) fn valid_change(e: &Env, change: &VaultChange) { match change { VaultChange::UpgradeDelay(secs) => upgrade_delay_in_bounds(e, *secs), VaultChange::Notice(secs) => notice_in_bounds(e, *secs), VaultChange::WindDownDelay(secs) => wind_down_delay_in_bounds(e, *secs), - VaultChange::Oracle(feed) => feed_bound_here(e, feed), + VaultChange::Oracle(feed) => feed_bound_here_and_answering(e, feed), VaultChange::Custodian(custodian) => custodian_not_the_vault(e, custodian), VaultChange::Upgrade(_) | VaultChange::GrantRole(..) diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 71045251..d423b821 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -1,3 +1,40 @@ +//! # Design +//! +//! Deposits and redemptions are requests. Each joins the open epoch; the epoch +//! is sealed and priced at the oracle's report, and each holder claims the +//! result. +//! +//! - **The manager seals, anyone prices.** The price is the oracle's, not the +//! caller's. The cost: a silent manager or attester stalls every request, and +//! after setup a replacement waits for the timelock. +//! - **Pricing records totals; claims mint and burn.** Pricing costs the same +//! for any number of requests. The cost: an epoch's rounding remainder stays +//! in `committed` and `pending_mint_shares` until its last request settles. +//! - **A readable price closes cancellation,** during the notice too, until the +//! feed goes stale or the NAV or the vault is paused. The cost: a report that +//! lapses lets requests leave a sealed epoch late. +//! - **Priced before paid.** A redemption is priced whether or not the cash is +//! there; its claim waits for the liquid reserve, and nothing goes to the +//! custodian meanwhile. The cost: an exit can wait on treasury. +//! - **A refund below one share is not reserved.** Pricing sees only totals, so +//! the refund comes from the free reserve at claim. The cost: treasury or a +//! wind-down round can spend it first, and the claim waits. +//! - **Checked totals.** An asset or share total fails with `AmountTooLarge` on +//! overflow, and with `AccountingMismatch` below zero unless signed, as +//! `net_deployed` is. The cost: books that disagree fail the call. +//! - **A freeze holds every exit.** A frozen holder's requests, claims and +//! cancellations fail with `Frozen` until it lifts: compliance can delay a +//! payout, never redirect it. The cost: even a covered claim waits. +//! - **A delisted holder leaves in cash.** Shares priced for them join their +//! redemption in the open epoch, or their wind-down entitlement. The cost: +//! another epoch at its price, and a wait while the vault itself is delisted. +//! - **Setup, then the timelock.** Until `finish_setup` checks the wiring, +//! admin changes are instant and deposits refused; after it, changes that +//! move value or cut a wait are timelocked. The cost: fixes take a week. +//! - **Wind-down claims run the compliance hooks:** surrendered shares burn +//! through the `destroyed` modules. The cost: one failing in a wind-down +//! blocks those claims, and no change can remove it then. + #![no_std] mod deposit; @@ -101,12 +138,6 @@ impl AsyncVault { oracle: Address, roles: VaultRoles, ) { - // The vault kit white-label topology specifies five distinct authorities: - // governance, manager, treasury, guardian, compliance, and attester. - // Although compliance is enforced on the identity verifier and attestation - // on the NAV oracle, the vault checks pairwise distinctness at construction - // time to prevent role misconfiguration across the kit ecosystem. Neither - // compliance nor attester is granted a direct role on the vault contract itself. guards::roles_distinct(e, &roles); access_control::set_admin(e, &roles.governance); diff --git a/contracts/async-vault/src/roles.rs b/contracts/async-vault/src/roles.rs index dbedcab8..3e095562 100644 --- a/contracts/async-vault/src/roles.rs +++ b/contracts/async-vault/src/roles.rs @@ -1,9 +1,9 @@ use soroban_sdk::{contracttype, symbol_short, Address, Symbol}; -/// Closes an epoch and sets its share price. Held by the Manager contract. +/// Seals the open epoch. Not the share token's `manager`, which the vault holds. pub const MANAGER_ROLE: Symbol = symbol_short!("manager"); -/// Moves capital to the custodian and funds the vault back. +/// Sends free reserve to the custodian. Anyone may fund the vault back. pub const TREASURY_ROLE: Symbol = symbol_short!("treasury"); pub const GUARDIAN_ROLE: Symbol = symbol_short!("guardian"); diff --git a/contracts/async-vault/src/test/compliance.rs b/contracts/async-vault/src/test/compliance.rs index ae84f4a0..d7bb16e6 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::ComplianceHook; +use stellar_tokens::rwa::compliance::{ComplianceError, ComplianceHook}; use super::*; @@ -113,6 +113,58 @@ fn a_compliance_module_that_refuses_the_mint_blocks_a_priced_claim() { ); } +#[test] +fn an_unbound_compliance_swap_lands_and_stops_every_move() { + let f = setup(); + let holder = f.holder(10); + let investor = f.investor(1_000); + let epoch = f.vault.request_deposit(&investor, &100); + 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 + ); + assert_eq!( + f.vault.try_claim_deposit(&investor, &epoch).err(), + token_not_bound + ); + assert_eq!( + f.vault.try_claim_redeem(&holder, &epoch).err(), + token_not_bound + ); + assert!(!f.vault.health().compliance_bound); +} + +#[test] +fn a_verifier_that_cannot_answer_counts_as_a_failed_check() { + let f = setup(); + let investor = f.investor(1_000); + + 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); + assert_eq!(f.share.identity_verifier(), silent); + + refused( + f.vault.try_request_deposit(&investor, &100), + VaultError::NotAllowlisted, + ); +} + #[test] fn a_delisted_holder_can_still_queue_an_exit() { let f = setup(); diff --git a/contracts/async-vault/src/test/deposit_cap.rs b/contracts/async-vault/src/test/deposit_cap.rs index e0c321d1..7f836314 100644 --- a/contracts/async-vault/src/test/deposit_cap.rs +++ b/contracts/async-vault/src/test/deposit_cap.rs @@ -178,3 +178,81 @@ fn zero_cap_blocks_all_deposits() { VaultError::DepositCapExceeded, ); } + +/// 1,000 deposited, priced and sent to the custodian; the cap is then 1,500. +fn capital_deployed_under_a_cap(f: &Fixture) -> Address { + let investor = f.investor(2_000); + f.vault.set_deposit_cap(&Some(1_000)); + let epoch = f.vault.request_deposit(&investor, &1_000); + f.close_epoch(); + f.fulfill_epoch_at(epoch, wad(1)); + f.vault.deploy_to_custodian(&f.treasury, &1_000); + f.vault.set_deposit_cap(&Some(1_500)); + investor +} + +#[test] +fn capital_funded_back_counts_once_toward_the_cap() { + let f = setup(); + let investor = capital_deployed_under_a_cap(&f); + + f.vault.fund(&f.custodian, &500); + + assert_eq!(f.vault.net_deployed(), 500); + f.vault.request_deposit(&investor, &500); +} + +#[test] +fn a_plain_transfer_back_counts_twice_toward_the_cap() { + let f = setup(); + let investor = capital_deployed_under_a_cap(&f); + + TokenClient::new(&f.e, &f.asset).transfer(&f.custodian, &f.vault.address, &500); + + assert_eq!(f.vault.net_deployed(), 1_000); + refused( + f.vault.try_request_deposit(&investor, &1), + VaultError::DepositCapExceeded, + ); +} + +#[test] +fn a_gain_paid_out_to_leavers_leaves_room_above_the_cap() { + let f = setup(); + let investor = capital_deployed_under_a_cap(&f); + f.vault.claim_deposit(&investor, &1); + StellarAssetClient::new(&f.e, &f.asset).mint(&f.custodian, &1_000); + f.vault.fund(&f.custodian, &2_000); + let exit = f.vault.request_redeem(&investor, &1_000); + f.fulfill_epoch(wad(2)); + f.vault.claim_redeem(&investor, &exit); + + assert_eq!(f.vault.net_deployed(), -1_000); + assert_eq!(f.balance(&f.vault.address), 0); + let newcomer = f.investor(3_000); + f.vault.request_deposit(&newcomer, &2_500); + refused( + f.vault.try_request_deposit(&newcomer, &1), + VaultError::DepositCapExceeded, + ); +} + +#[test] +fn a_loss_at_the_custodian_still_counts_toward_the_cap() { + let f = setup(); + let investor = capital_deployed_under_a_cap(&f); + f.vault.claim_deposit(&investor, &1); + f.vault.fund(&f.custodian, &500); + let exit = f.vault.request_redeem(&investor, &1_000); + f.fulfill_epoch(wad(1) / 2); + f.vault.claim_redeem(&investor, &exit); + + assert_eq!(f.vault.net_deployed(), 500); + assert_eq!(f.balance(&f.vault.address), 0); + let newcomer = f.investor(1_500); + f.vault.request_deposit(&newcomer, &1_000); + refused( + f.vault.try_request_deposit(&newcomer, &1), + VaultError::DepositCapExceeded, + ); +} diff --git a/contracts/async-vault/src/test/guards.rs b/contracts/async-vault/src/test/guards.rs index f3b8226d..b484fd16 100644 --- a/contracts/async-vault/src/test/guards.rs +++ b/contracts/async-vault/src/test/guards.rs @@ -281,9 +281,11 @@ fn custodian_not_the_vault_refuses_the_vault_itself() { } #[test] -fn feed_bound_here_refuses_an_unbound_feed_or_a_contract_that_is_not_a_feed() { +fn feed_bound_here_and_answering_refuses_an_unbound_feed_or_a_contract_that_is_not_a_feed() { let f = setup(); - holds(&f, || guards::feed_bound_here(&f.e, &f.oracle.address)); + holds(&f, || { + guards::feed_bound_here_and_answering(&f.e, &f.oracle.address) + }); let unbound = f.e.register( NavOracleContract, @@ -297,13 +299,13 @@ fn feed_bound_here_refuses_an_unbound_feed_or_a_contract_that_is_not_a_feed() { // The compliance contract is bound to this vault but answers no feed view. for not_a_feed_here in [unbound, f.share.compliance()] { refuses(&f, VaultError::FeedNotBound, || { - guards::feed_bound_here(&f.e, ¬_a_feed_here) + guards::feed_bound_here_and_answering(&f.e, ¬_a_feed_here) }); } } #[test] -fn change_in_bounds_checks_the_bounds_of_each_change() { +fn valid_change_checks_each_kind_of_change() { let f = setup(); let stranger = Address::generate(&f.e); for change in [ @@ -317,7 +319,7 @@ fn change_in_bounds_checks_the_bounds_of_each_change() { VaultChange::Admin(stranger.clone()), VaultChange::Oracle(f.oracle.address.clone()), ] { - holds(&f, || guards::change_in_bounds(&f.e, &change)); + holds(&f, || guards::valid_change(&f.e, &change)); } for (change, error) in [ @@ -346,7 +348,7 @@ fn change_in_bounds_checks_the_bounds_of_each_change() { VaultError::InvalidCustodian, ), ] { - refuses(&f, error, || guards::change_in_bounds(&f.e, &change)); + refuses(&f, error, || guards::valid_change(&f.e, &change)); } } diff --git a/contracts/async-vault/src/test/wind_down.rs b/contracts/async-vault/src/test/wind_down.rs index fee93d15..b9428aec 100644 --- a/contracts/async-vault/src/test/wind_down.rs +++ b/contracts/async-vault/src/test/wind_down.rs @@ -1,5 +1,5 @@ use proptest::prelude::*; -use soroban_sdk::IntoVal; +use soroban_sdk::{contract, contracterror, contractimpl, panic_with_error, IntoVal}; use super::*; @@ -409,6 +409,51 @@ fn a_holder_who_waits_collects_every_round_at_once() { assert_eq!(all_at_once, first + second); } +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +enum ModuleError { + BurnRefused = 1, +} + +#[contract] +struct FailsEveryBurn; + +#[contractimpl] +impl FailsEveryBurn { + pub fn on_destroyed(e: Env, _from: Address, _amount: i128, _token: Address) { + panic_with_error!(&e, ModuleError::BurnRefused); + } +} + +#[test] +fn a_module_failing_on_burn_blocks_wind_down_claims_and_cannot_be_removed() { + let f = setup(); + let holder = f.holder(500); + let module = f.e.register(FailsEveryBurn, ()); + f.add_compliance_module(ComplianceHook::Destroyed, &module); + wind_down_now(&f); + f.vault.finalize_wind_down_round(); + + assert_eq!( + f.vault.try_claim_wind_down(&holder), + Err(Ok(ModuleError::BurnRefused.into())) + ); + assert_eq!(f.shares(&holder), 500); + assert_eq!(f.balance(&holder), 0); + + let rules = ComplianceClient::new(&f.e, &f.share.compliance()); + assert_eq!( + rules.try_propose_change(&ComplianceChange::RemoveModule( + ComplianceHook::Destroyed, + module, + )), + Err(Ok( + ::compliance::ComplianceContractError::WindDownActive.into() + )) + ); +} + #[test] fn claiming_burns_the_shares_and_pays_once() { let f = setup(); diff --git a/contracts/compliance/src/lib.rs b/contracts/compliance/src/lib.rs index 63b19938..a56add4e 100644 --- a/contracts/compliance/src/lib.rs +++ b/contracts/compliance/src/lib.rs @@ -1,5 +1,24 @@ +//! # Design +//! //! Testnet-only stub — in production this is an external modular-compliance //! module (ERC-3643), not part of this product. Do not deploy to mainnet. +//! +//! - **No rule of its own.** With no module registered every check passes; the +//! allowlist lives in the identity verifier. The cost: any other rule needs a +//! module or another compliance contract. +//! - **Changes wait for the vault's timelock.** After setup, module, binding +//! and admin changes wait, and a wind-down refuses them. The cost: a faulty +//! module takes a full delay to remove, and stays in a wind-down. +//! - **An unbound token fails.** The hooks the token calls after each mint, +//! transfer and burn refuse it when it is not bound. The cost: unbinding it +//! stops all three, the vault's claims included. +//! - **The operator is not read.** OpenZeppelin's module and binding calls take +//! an `operator`; the stub asks the admin to sign instead. The cost: the +//! admin cannot delegate those calls. +//! - **No upgrade.** The stub has no upgrade path. The cost: a fix means a new +//! deployment and a token `Compliance` change, which waits for the vault's +//! timelock after setup. + #![no_std] mod governance; diff --git a/contracts/identity-verifier/src/lib.rs b/contracts/identity-verifier/src/lib.rs index 77e1e0ac..f535b542 100644 --- a/contracts/identity-verifier/src/lib.rs +++ b/contracts/identity-verifier/src/lib.rs @@ -1,5 +1,24 @@ +//! # Design +//! //! Testnet-only stub — in production this is an external ERC-3643 identity //! registry (a trusted issuer's), not part of this product. Do not deploy to mainnet. +//! +//! - **A yes or no per address.** No claims, topics or issuers, and an address +//! never set is refused. The cost: no expiry and no per-jurisdiction rule; a +//! deployment that needs them brings its own registry. +//! - **Instant edits.** The admin lists and delists at once, with no timelock. +//! The cost: a stolen admin key can list or delist any account at once; a +//! delisted holder still leaves in cash. +//! - **A fixed admin, no upgrade.** The admin account's signers can change in +//! place, but the account cannot. The cost: replacing it, or the code, needs +//! a new verifier and a token change that waits for the timelock. +//! - **No recovery account.** `recovery_target` is always `None`, so +//! `recover_balance` always fails. The cost: a lost wallet's shares move by +//! `legal_transfer`, signed by `compliance` and the token's admin. +//! - **OpenZeppelin's error code.** A refusal is +//! `RWAError::IdentityVerificationFailed` (304); the stub has no error enum. +//! The cost: the code says what failed, not which contract refused. + #![no_std] use soroban_sdk::{ diff --git a/contracts/nav-oracle/src/lib.rs b/contracts/nav-oracle/src/lib.rs index fc6c0983..bb130e1d 100644 --- a/contracts/nav-oracle/src/lib.rs +++ b/contracts/nav-oracle/src/lib.rs @@ -1,3 +1,34 @@ +//! # Design +//! +//! The feed the vault prices epochs from. An attester reports the share price, +//! and the oracle keeps the latest report that passes the limits the admin +//! sets. +//! +//! - **The ledger dates a report.** It expires at the earlier of `expires_at` +//! and its acceptance plus `freshness_duration`. The cost: the vault compares +//! acceptance times, not valuation dates. +//! - **No report while the NAV is paused.** The guardian or the admin pauses +//! it; only the admin lifts it. The cost: the vault prices nothing meanwhile, +//! and sealed epochs reopen to cancellation. +//! - **Tightening waits too.** After setup only a shorter `freshness_duration` +//! is instant: a tighter cap or band moves value too. The cost: an urgent +//! limit change waits the full delay. +//! - **The band applies on read.** A stored report outside the band reads as +//! stale. The cost: narrowing the band away from it stops pricing, and new +//! reports far from it fail the cap until `clear_latest`. +//! - **Both moves are capped.** Both caps are required; a 10,000 bps fall cap +//! lets any loss land at once. The cost of a lower one: a loss takes several +//! reports, and exits priced meanwhile are paid above value. +//! - **Revoking is instant, granting waits.** After setup a new attester or +//! guardian waits for the timelock. The cost: replacing a compromised +//! attester keeps the NAV paused for the whole delay. +//! - **Clearing needs a paused NAV.** `clear_latest` runs only while the NAV is +//! paused, so the next report is a separate act. The cost: that report has no +//! earlier price to cap it; only the band bounds it. +//! - **One report per cooldown.** The cooldown runs from one second to +//! `freshness_duration`, so no two reports share a ledger. The cost: an epoch +//! sealed just after a report waits up to a cooldown for the next. + #![no_std] mod extend; diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index 1a3796d7..054250bf 100644 --- a/contracts/share-token/src/contract.rs +++ b/contracts/share-token/src/contract.rs @@ -27,7 +27,7 @@ pub struct ShareToken; #[contractimpl] impl ShareToken { /// Creates the token with 7 decimals, its admin, its compliance authority - /// and the two contracts every move is checked against. + /// and the two contracts that check each `transfer` and mint. /// /// # Arguments /// @@ -208,7 +208,7 @@ impl ShareToken { } /// Moves shares under a legal order, at once. The pause, freezes and the - /// compliance rules do not apply. + /// compliance rules' check do not apply. /// /// # Arguments /// diff --git a/contracts/share-token/src/lib.rs b/contracts/share-token/src/lib.rs index a9f55cdd..567750d3 100644 --- a/contracts/share-token/src/lib.rs +++ b/contracts/share-token/src/lib.rs @@ -1,3 +1,28 @@ +//! # Design +//! +//! The vault's share token: OpenZeppelin's RWA token, which checks identity and +//! the compliance rules on every `transfer` and mint; forced transfers skip +//! both. The kit adds its roles, its guards and the vault's timelock. +//! +//! - **No clock of its own.** After setup, upgrades, grants, `manager` revokes, +//! hand-overs and swaps wait for the vault's timelock. The cost: a vault +//! pause holds them; a wind-down refuses them. +//! - **Only the vault is manager.** It mints, burns and moves anyone's shares; +//! a forced move skips the token's checks, so the vault checks receivers. The +//! cost: granting it waits; revoking too, as claims need it. +//! - **Two signers move a holder's shares.** A legal transfer or recovery needs +//! `compliance` and the admin, as two accounts. The cost: an order needs +//! both. A zero-share order passes and is published. +//! - **The vault's address is off limits:** once bound, it is never frozen and +//! no order moves shares in or out, since it holds others' escrow. The cost: +//! orders cannot reach escrow; a frozen holder's exits wait. +//! - **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. + #![no_std] mod contract; mod error; diff --git a/contracts/share-token/src/test.rs b/contracts/share-token/src/test.rs index 8052833e..8d752e8c 100644 --- a/contracts/share-token/src/test.rs +++ b/contracts/share-token/src/test.rs @@ -720,6 +720,36 @@ mod legal_transfer { assert_eq!(f.token.balance(&destination), 250); } + #[test] + fn a_zero_share_legal_transfer_passes_and_is_published() { + let e = Env::default(); + let (f, holder, destination, _) = live(&e); + + f.token.legal_transfer( + &holder, + &destination, + &0, + &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: 0, + reference: order(&e), + } + .to_xdr(&e, &f.token.address) + ) + ); + assert_eq!(f.token.balance(&holder), 1_000); + assert_eq!(f.token.balance(&destination), 0); + } + #[test] fn compliance_alone_cannot_move_shares() { let e = Env::default(); diff --git a/crates/strata-bindings/src/lib.rs b/crates/strata-bindings/src/lib.rs index 98f582c1..a7d70730 100644 --- a/crates/strata-bindings/src/lib.rs +++ b/crates/strata-bindings/src/lib.rs @@ -1,3 +1,17 @@ +//! # Design +//! +//! The interfaces the kit's contracts share, and their error code blocks. +//! +//! - **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. +//! - **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. + #![no_std] mod errors; diff --git a/crates/strata-storage/src/lib.rs b/crates/strata-storage/src/lib.rs index 6ad5f337..9c79aec1 100644 --- a/crates/strata-storage/src/lib.rs +++ b/crates/strata-storage/src/lib.rs @@ -1,3 +1,21 @@ +//! # Design +//! +//! Storage helpers that keep the kit's own entries alive. Every read and every +//! write through them asks the network to renew the entry it touches. +//! +//! - **Thirty days, renewed a day short.** Each entry, the instance included, +//! goes back to 30 days once it falls a day short. The cost: a busy entry +//! costs about a day of rent per day; an idle one archives. +//! - **Extended in the call that creates it.** A write renews too, so a new +//! entry starts with at least its target. The cost: that call pays for it, or +//! for the network's minimum when that is longer. +//! - **The same lengths on every network,** fixed in code, so the Wasm tested +//! on testnet is the one that ships; each network's minimum lifetime is a +//! floor for new entries. The cost: no lengths tuned per network. +//! - **No cap per call.** A cap would let rarely read entries archive sooner, +//! and a mainnet restore pays at least 120 days of rent. The cost: the first +//! call after a quiet spell renews all it touches. + #![no_std] use core::fmt::Debug; diff --git a/crates/strata-timelock/src/clock.rs b/crates/strata-timelock/src/clock.rs index e9471570..020ff893 100644 --- a/crates/strata-timelock/src/clock.rs +++ b/crates/strata-timelock/src/clock.rs @@ -10,7 +10,8 @@ use crate::setup; pub struct Clock { /// Seconds a change proposed now waits. pub delay: u64, - /// The longest an exit takes at the source. A change never waits less. + /// The source's notice: how long a closed epoch waits before it can be + /// priced. A change never waits less. pub exit_window: u64, /// Seconds the source has spent paused, a running pause included. pub paused_secs: u64, diff --git a/crates/strata-timelock/src/delay.rs b/crates/strata-timelock/src/delay.rs index ca560fa0..12772b11 100644 --- a/crates/strata-timelock/src/delay.rs +++ b/crates/strata-timelock/src/delay.rs @@ -1,6 +1,6 @@ pub const DAY_DURATION: u64 = 24 * 60 * 60; -/// Short enough to fix a defect in a week, long enough for anyone who -/// dislikes a change to leave before it lands. +/// Short enough to fix a defect in a week, long enough to warn holders before +/// a change lands. pub const MIN_DELAY: u64 = 7 * DAY_DURATION; /// No delay can wall a contract off from future changes. pub const MAX_DELAY: u64 = 90 * DAY_DURATION; diff --git a/crates/strata-timelock/src/lib.rs b/crates/strata-timelock/src/lib.rs index eb3d3139..203ea026 100644 --- a/crates/strata-timelock/src/lib.rs +++ b/crates/strata-timelock/src/lib.rs @@ -1,6 +1,30 @@ +//! # Design +//! //! A timelock for privileged changes. One contract owns the clock: its delay, -//! its exit window, its pause time and whether it winds down. That contract and -//! every contract bound to it queue their changes against the same clock. +//! its notice (the clock's `exit_window`), its pause time and whether it winds +//! down. That contract and every contract bound to it queue their changes +//! against the same clock. +//! +//! - **One clock, the vault's.** The token, the oracle and the compliance +//! contract bind to it once, during setup, and read it on every proposal and +//! apply. The cost: a vault pause holds every change in the kit. +//! - **A setup window first.** Changes are instant until setup ends, nothing +//! queues before it, and it never reopens. The cost: a mistake found after +//! setup takes a full delay to fix. +//! - **One queue per contract.** Every kind of change shares it, at most +//! `MAX_PENDING` at once, and no id is reused. The cost: a full queue refuses +//! proposals until a change lands or is cancelled. +//! - **The delay is an announcement period.** It promises notice, not an exit, +//! which also needs a sealed epoch, a price and cash. Paused time does not +//! count. The cost: a long pause delays every fix. +//! - **Never shorter than the notice.** Each attempt to apply compares the +//! change's delay with the notice then. The cost: a raised notice holds back +//! waiting changes until it comes down or they are proposed again. +//! - **Nothing lands in a wind-down.** Nothing is proposed or applied once it +//! starts. The cost: nothing can be fixed during a wind-down, a failing +//! compliance module included. +//! - **Seven to ninety days.** `MIN_DELAY` and `MAX_DELAY` bound the delay the +//! vault sets. The cost: the fastest fix after setup takes a week. #![no_std] diff --git a/crates/strata-timelock/src/refusal.rs b/crates/strata-timelock/src/refusal.rs index 2d1ae57b..fa718be7 100644 --- a/crates/strata-timelock/src/refusal.rs +++ b/crates/strata-timelock/src/refusal.rs @@ -8,7 +8,7 @@ pub enum Refusal { Paused, /// The change's delay, plus the time paused since, has not run yet. NotElapsed, - /// The change's delay is shorter than the current exit window. + /// The change's delay is shorter than the source's notice now. BelowExitWindow, /// No pending change has this id. NotFound, diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index b1174021..05721e18 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -13,9 +13,9 @@ This document provides the complete authorization and role model specification f - A role entrypoint checks the role of the `caller` or `operator` it names, then asks that account to sign. `legal_transfer` and `recover_balance` also ask the admin, a different account, to sign the same call. - The testnet stubs are the exception. The compliance contract's `bind_token`, `unbind_token`, `add_module_to` and `remove_module_from`, and the identity verifier's `set_claim_topics_and_issuers`, keep the `operator` argument their trait fixes but do not read it. `#[only_admin]` asks the stored admin to sign, so each still asks for one signature. Production replaces both stubs with external contracts (§5.4, §5.5). 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**: +4. **Five Separate Authorities, plus the Vault's `manager` Role**: - **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. + - **Manager (vault role, not an authority)**: 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. @@ -50,10 +50,10 @@ In `scripts/harness/deploy.ts` and automated deployment scripts, the separation ## 3. Vault Constructor Authority Handling `AsyncVault::__constructor` accepts `VaultRoles`, which includes `governance`, `manager`, `treasury`, `guardian`, `compliance`, and `attester`. -- **Why `compliance` and `attester` are kept in the constructor**: - The vault kit enforces an end-to-end separation of duties. At construction, the vault validates pairwise distinctness: +- **What the constructor does with `compliance` and `attester`**: + It checks four pairs and refuses an address that appears in both seats (`RolesNotDistinct`): `treasury != guardian`, `treasury != governance`, `compliance != governance`, and `compliance != treasury`. - Although compliance is enforced on `IdentityVerifier` / `Compliance` and attestation on `NavOracle`, the vault records and validates the full 5-authority topology at instantiation time to prevent cross-contamination of duties. Neither `compliance` nor `attester` is granted a direct role on the vault itself. + No other pair is compared: governance and guardian may be one account, and the manager is unconstrained. The vault stores neither `compliance` nor `attester` and grants neither a role. `compliance` is read only by that check; `attester` is neither stored nor compared, so nothing ties it to the oracle's attester. Compliance acts on the identity verifier and the share token, and attestation on the oracle. --- @@ -71,7 +71,7 @@ The following entrypoints are open to any caller on purpose: | **`AsyncVault`** | `request_deposit` | `holder.require_auth()` | Investor entrypoint. Deposits escrowed cash; guarded by `when_not_paused`, not winding down, and refused with `SetupOpen` until the vault's setup finishes, so every deposit is covered by the timelock. | | **`AsyncVault`** | `claim_deposit` | `holder.require_auth()` | Investor entrypoint. Mints the shares once the epoch is priced; the token checks the receiver on `mint`. A delisted holder's shares become an exit instead (`DepositConvertedToExit`): they join the holder's redemption in the open epoch, or the holder's wind-down entitlement during a wind-down. A frozen holder's claim is held (`Frozen`). | | **`AsyncVault`** | `cancel_deposit` | `holder.require_auth()` | Investor exit while the epoch cannot be priced. Refunds the whole escrowed deposit in one step, delisted or not. A frozen holder is held (`Frozen`). | -| **`AsyncVault`** | `request_redeem` | `holder.require_auth()` | Investor exit. Always open, even during pause. | +| **`AsyncVault`** | `request_redeem` | `holder.require_auth()` | Investor exit. Open during a pause and to a delisted holder; refused during a wind-down (`WindDownActive`). | | **`AsyncVault`** | `claim_redeem` | `holder.require_auth()` | Investor exit. Pays settlement asset once covered. | | **`AsyncVault`** | `cancel_redeem` | `holder.require_auth()` | Investor exit while the epoch cannot be priced. Outside a wind-down the vault checks the holder itself and returns the escrowed shares with the manager `forced_transfer`; it refuses a delisted holder (`NotAllowlisted`) and a move the compliance rules refuse (`HolderNotEligible`). In an active wind-down the escrow is surrendered into the holder's wind-down entitlement instead (`RedemptionSurrendered`). A frozen holder is held (`Frozen`) either way. | diff --git a/docs/COMPLIANCE_MATRIX.md b/docs/COMPLIANCE_MATRIX.md index 9e01af2b..3131cd90 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 refund for a deposit priced at zero shares is the exception: it is not tracked as owed, so it waits in the free reserve, where a wind-down round can credit it to other holders (see the limits in the design doc's [§8.7](./strata-product-and-architecture.md#87-treasury-and-custodian)). 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. + - 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 refund for a deposit priced at zero shares is the exception: it is not tracked as owed, so it waits in the free reserve, where treasury can deploy it or a wind-down round can credit it to other holders (see the limits in the design doc's [§8.7](./strata-product-and-architecture.md#87-treasury-and-custodian)). 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. @@ -31,7 +31,7 @@ Every investor account in the vault ecosystem exists in one of three primary com | Operation | Allowlisted | De-listed (Non-Frozen) | Frozen (Address or Partial) | Contract Mechanics & Regulatory Rationale | |---|---|---|---|---| -| **`request_deposit`** | ✅ Allowed | ❌ **Refused** | ❌ **Refused** | Before any cash moves, the vault asks the share token whether the address is frozen and then asks the token's identity verifier. It refuses a frozen address (`Frozen`) and an address the token would not let hold shares (`NotAllowlisted`). A partial freeze is not checked. | +| **`request_deposit`** | ✅ Allowed | ❌ **Refused** | ❌ **Refused** | Before any cash moves, the vault asks the share token whether the address is frozen and then asks the token's identity verifier. It refuses a frozen address (`Frozen`) and an address the token would not let hold shares (`NotAllowlisted`); a verifier that cannot answer counts as a refusal. A partial freeze is not checked. | | **`cancel_deposit`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); the escrow stays | **Escape Hatch**: Calls `refund_deposit`. Escrowed settlement asset (USDC) is refunded directly to `holder`. The vault checks `is_frozen` on the share token before it refunds anything; once past that, cash refunds bypass share token compliance checks. A partial freeze is not checked. | | **`claim_deposit`** | ✅ Allowed | ↪ **Moved to the exit queue** | ⛔ **Held** | A receiver off the allowlist gets no shares: the vault mints them into its own escrow and adds them to the holder's redemption in the open epoch (`DepositConvertedToExit`), paid in cash at that epoch's price. In a wind-down they join the holder's wind-down entitlement. A compliance module that refuses the mint (`can_create`) still blocks the claim. A receiver whose address is frozen, listed or not, is refused with `Frozen` (6071) and keeps the priced deposit until the freeze is lifted. A partial freeze is not checked. | | **`request_redeem`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); a partial freeze caps the request to the free balance | **Exit Right**: the vault checks `is_frozen` and `get_frozen_tokens` on the share token before it escrows anything with `RWATokenClient::forced_transfer`. A de-listed, non-frozen holder can still start an exit; a frozen one cannot, and a partial freeze caps the request to the shares `get_frozen_tokens` leaves free. | @@ -99,7 +99,7 @@ The share token is OpenZeppelin's RWA token. The allowlist and freeze checks sit When the vault returns escrowed shares with `forced_transfer`, it makes the receiver checks itself first: the freeze, `verify_identity` and `can_transfer`. It does not check the token pause. -The kit's compliance contract registers no modules, so `can_transfer` and `can_create` always allow today. Until rule modules are added, the allowlist and freeze rules are exactly the token checks in the table above. Adding or removing a module waits for the vault's timelock once its setup is finished, so a rule change never lands sooner than an exit completes. +The kit's compliance contract registers no modules, so `can_transfer` and `can_create` always allow today. Until rule modules are added, the allowlist and freeze rules are exactly the token checks in the table above. Adding or removing a module waits for the vault's timelock once its setup is finished, so holders get at least the delay as notice of a rule change. An exit still needs a sealed epoch, a price and cash, so the delay does not promise that one completes first. --- diff --git a/docs/TESTING.md b/docs/TESTING.md index ef67d718..3ebfb0f1 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -158,6 +158,17 @@ fixture wires them as the deploy does and finishes the vault's setup; | 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` | +## Contract spec docs and error codes + +The contract spec keeps the first 1,024 bytes of each doc and drops the rest +without a warning. Each contract's error codes stay inside the block +`crates/strata-bindings` gives it. + +| Behaviour | Tests | Key test names | +| --- | --- | --- | +| Every entrypoint and error variant doc of the five contracts fits in 1,024 bytes | [`crates/strata-bindings/tests/spec_docs.rs`](../crates/strata-bindings/tests/spec_docs.rs) | `every_contract_doc_fits_the_spec` | +| An error enum declared through `error_enum!` compiles only while every code stays in its contract's block | [`crates/strata-bindings/src/errors.rs`](../crates/strata-bindings/src/errors.rs) (doctests) | the in-range example compiles; the out-of-range one fails with `E0080` | + ## Wind-down on the network `scripts/harness/wind-down.test.ts` deploys a throwaway vault set on the local diff --git a/docs/kit-spec.md b/docs/kit-spec.md index eba68d2d..b0a6504e 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -82,8 +82,9 @@ The contracts enforce a set of mathematical and state-machine invariants across $`(a \cdot \text{SCALE}) - (s \cdot \text{NAV}) < \text{NAV}`$ - **Settlement**: pricing records what each epoch promised (`shares_promised`, `assets_promised`, see `get_epoch_settlement`). Claims floor one by one, so together they pay at most that. When the last request of a priced epoch settles, the remainder leaves `pending_mint_shares` and `committed` and the vault publishes `EpochReconciled { epoch, share_dust, cash_dust }`. The cash stays in the reserve, owed to nobody, and reaches every holder through the NAV. A running total that would go below zero fails with `AccountingMismatch` (6070) instead of clamping. - **A request that can never settle** keeps its epoch open, and the two cases leave different amounts behind. A zero-asset redemption whose holder may not take the shares back (`HolderNotEligible`, `a_request_that_cannot_settle_keeps_its_epoch_open`) leaves only the epoch's ordinary rounding remainder in `committed` — fewer units than the epoch had redemption requests — because that request was never owed any cash. A priced deposit whose mint a compliance module refuses (`a_refused_mint_keeps_a_priced_deposit_epoch_open`) is not rounding dust: the mint reverts, so that deposit's whole share entitlement stays in `pending_mint_shares`, on top of the epoch's own remainder, until the mint is allowed. Either way it is still owed to nobody. +- **A deposit worth less than one share base unit** mints nothing and is refunded at claim, from the free reserve (`a_deposit_below_one_share_is_refunded_at_claim`). Pricing moves the epoch's whole deposit into the reserve and records no refund, so `deploy_to_custodian` or a wind-down round can spend that cash first; the claim then fails with `ClaimNotCovered` until the free reserve covers it again (`a_dust_refund_never_takes_committed_money`, `a_zero_share_refund_is_refused_once_a_round_has_taken_the_reserve`). This is accepted: pricing sees only the epoch's totals, so reserving these refunds would mean visiting every request, and each is worth less than one share base unit. -**Settlement and upgrades.** The settlement record counts a request only from the moment it exists. A vault upgraded in place to this version while any request is open would under-count its open epochs: claims in epochs priced before the upgrade would revert, and a mixed epoch could reconcile early. This version must therefore be deployed fresh, or reached by upgrade only once no request is open anywhere in the vault. +**Settlement and upgrades.** The settlement record counts a request only from the moment it exists. A vault upgraded in place to this version while any request is open would under-count its open epochs: claims in epochs priced before the upgrade would revert, and a mixed epoch could reconcile early. This version is therefore deployed fresh: an earlier vault is not upgraded to it in place. ### Invariant 8: Bounded Governance Timelocks - **Definition**: Upgrades and wind-downs cannot execute without advance notice, and delays cannot be set to invalid boundaries. @@ -144,12 +145,12 @@ Deploying the Strata Vault Kit involves five contracts configured via `environme | **`async_vault`** | `roles.governance` | `Address` | Governance multisig authority | `async_vault.constructor_args` | | **`async_vault`** | `roles.treasury` | `Address` | Treasury multisig authority | `async_vault.constructor_args` | | **`async_vault`** | `roles.guardian` | `Address` | Guardian multisig authority | `async_vault.constructor_args` | -| **`async_vault`** | `roles.manager` | `Address` | Manager authority (closes epochs) | `async_vault.constructor_args` | +| **`async_vault`** | `roles.manager` | `Address` | Account that gets the vault's `manager` role (closes epochs); a role, not one of the five authorities | `async_vault.constructor_args` | | **`async_vault`** | `roles.compliance` | `Address` | Compliance multisig authority | `async_vault.constructor_args` | -| **`async_vault`** | `roles.attester` | `Address` | Attestation authority (mirrors oracle reporter) | `async_vault.constructor_args` | +| **`async_vault`** | `roles.attester` | `Address` | Attestation authority. Not read: nothing checks that it matches the oracle's attester | `async_vault.constructor_args` | -#### Pairwise Role Separation Enforced at Construction -The `async_vault` constructor strictly verifies pairwise separation across authorities: +#### Role Separation Checked at Construction +The `async_vault` constructor refuses these four pairs of equal addresses and compares no other: - `treasury != guardian` - `treasury != governance` - `compliance != governance` @@ -163,7 +164,7 @@ The `async_vault` constructor strictly verifies pairwise separation across autho |---|---|---|---| | **Standing Notice** | `set_notice(secs: u64)`; after setup, lowering goes through `propose_change(Notice(secs))` → `apply_change(id)` | Governance | Notice wait before pricing. Default: `0`. Must satisfy $`\text{secs} \le \text{upgrade\_delay}`$ and $`\text{secs} \le 30 \text{ days}`$ (`MAX_NOTICE_SECS`), checked again when a `Notice` change applies. Raising is instant. | | **Custodian Address** | `set_custodian(custodian: Address)` during setup; afterwards `propose_change(Custodian(address))` → `apply_change(id)` | Governance | Recipient address for off-chain capital deployment. Required before `finish_setup` and `deploy_to_custodian`. Never the vault itself (`InvalidCustodian`), checked when it is set, proposed and applied. The deploy sets it before setup closes. | -| **Deposit Cap** | `set_deposit_cap(cap: Option)` | Governance | Maximum capacity for deposited capital. `None` = unbounded. Refuses negative cap. Instant both ways, also after setup. | +| **Deposit Cap** | `set_deposit_cap(cap: Option)` | Governance | Maximum capacity for deposited capital (see **Supply and Capital Figures**). `None` = unbounded. Refuses negative cap. Instant both ways, also after setup. The cap is vault-wide; there is no cap per investor. | | **Wind-Down Delay** | `set_wind_down_delay(secs: u64)`; after setup, lowering goes through `propose_change(WindDownDelay(secs))` → `apply_change(id)` | Governance | Minimum notice before wind-down activation. Default and minimum 7 days (`MIN_WIND_DOWN_DELAY`), maximum 90 days (`MAX_WIND_DOWN_DELAY`). Raising is instant. | | **Upgrade Delay** | `propose_change(UpgradeDelay(secs))` → `apply_change(id)` | Governance | Timelock delay for wasm upgrades. Must be $`\ge 7 \text{ days}`$, $`\le 90 \text{ days}`$, and $`\ge \text{notice}`$. | | **Oracle Configuration** | `set_config(config: OracleConfig)`; after setup, `propose_change(Config(config))` → `apply_change(id)` | Governance | Updates oracle parameters within the constructor's bounds. `set_config` is instant during the oracle's setup. After it, `set_config` is instant only for a shorter `freshness_duration`: later reports go stale sooner, and no value moves. Every other change, tighter limits included, goes through `propose_change(Config(config))` → `apply_change(id)`, and the bounds are checked again when it applies. A tighter fall cap or a longer cooldown can hold the price above a real loss, and a narrower band can stop pricing. Applying a `Config` change sets every field, freshness included; to keep a cut made after the proposal, cancel the queued change and propose again. | @@ -186,16 +187,22 @@ The `async_vault` constructor strictly verifies pairwise separation across autho | `addresses_distinct` | The asset, the share token, the oracle and the vault are four different addresses. | | `custodian_set` | A custodian is set, and it is not the vault itself. | | `feed_answers` | The oracle answers `state()` and `freshness_duration()`. A new feed with no report passes. `oracle_bound` cannot tell a feed from another contract bound to this vault, such as the compliance contract. | -| `feed_valid` | The oracle reports `Valid`: a report exists, it has not expired and the NAV is not paused. | +| `feed_valid` | The oracle reports `Valid`: a report exists, it has not expired, it is in band and the NAV is not paused. | | `freshness_covers_notice` | The oracle's `freshness_duration` is at least the vault's notice. | `freshness_covers_notice` false means a report can expire before the notice ends. A sealed epoch's cancellation window can then reopen on its own, so set `freshness_duration` above the notice. `feed_valid` false right after a deploy is normal until the first attestation. +### Supply and Capital Figures + +- `total_economic_supply()` counts share base units held outside the vault plus those priced for deposits and not yet minted: the token's `total_supply()`, less the vault's own share balance, plus `pending_mint_shares()`. It leaves out every share in the vault's escrow. That includes the shares of a redemption that is not priced yet: the next price still applies to them, and a cancellation can return them (`economic_supply_excludes_shares_locked_in_redemption_escrow`). The first wind-down round's supply snapshot counts those shares, so the two figures differ while redemptions wait to be priced. +- Deposited capital, which the deposit cap limits, is the asset the vault holds plus `net_deployed()`, less `committed()`. It counts money in and out, not the vault's value (what the vault and the custodian hold): a deposit request adds what it brings, and a priced redemption takes off what it pays. A gain or a loss at the custodian does not move it, even once funded back. After a gain, the figure is below the vault's value. Once leavers are paid the gain, it can go negative, and the cap then admits more than its own amount (`a_gain_paid_out_to_leavers_leaves_room_above_the_cap`). After a loss, the figure stays above the vault's value, and the cap refuses deposits sooner (`a_loss_at_the_custodian_still_counts_toward_the_cap`). +- `net_deployed()` is what `deploy_to_custodian` sent less what `fund` brought back; it goes negative once more came back than went out. The custodian repays through `fund`. A plain transfer to the vault raises the vault's balance but leaves `net_deployed` as it was, so from then on the same money counts twice in deposited capital and the cap refuses deposits sooner. + --- ## 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, except the share token's `legal_transfer` and `recover_balance`, which need compliance and governance in the same call. Each call asks each account it needs for one signature. An admin entrypoint the kit defines takes no caller argument: the stored admin signs it. +Strata distributes control across five native Stellar multisig authorities, two `manager` roles and permissionless public entrypoints. Every privileged function needs exactly one of these authorities or roles, except the share token's `legal_transfer` and `recover_balance`, which need compliance and governance in the same call. Each call asks each account it needs for one signature. An admin entrypoint the kit defines takes no caller argument: the stored admin signs it. ### Authority Capabilities & Anti-Capabilities @@ -203,11 +210,33 @@ Strata distributes control across five native Stellar multisig authorities and p |---|---|---|---| | **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, the compliance contract's module and token-binding changes, 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. | +| **Treasury** | Operational capital multisig | `deploy_to_custodian` (moves free reserve). Capital comes back through `fund`, which anyone may call. | **Cannot deploy while anything is uncovered**; cannot touch escrowed subscription assets; cannot alter parameters or pause. | +| **Attester** | High-security attestation multisig | `attest` (submits a NAV report: the price and its expiry). | **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`; `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. | +| **Manager (vault role)** | Operational keeper account; a role, not one of the five authorities | `close_epoch`: seals the open epoch, nothing else. | **Cannot choose prices**; cannot mint, burn or move shares; cannot stop a sealed epoch from being priced, though not sealing stalls every request; cannot alter timelocks. After setup, granting or revoking it waits for the timelock. | +| **Manager (share-token role)** | The vault contract, alone: the vault's `finish_setup` refuses while another account holds it | `mint`, `burn`, `forced_transfer` on the share token: the vault mints and burns at claim and moves escrowed shares. | Cannot freeze, recover or make a legal transfer. After setup, granting or revoking it waits for the vault's timelock. See [`AUTHORIZATION_MATRIX.md`](./AUTHORIZATION_MATRIX.md) §2. | +| **Permissionless** | Any caller / keeper / investor | `fulfill_epoch`, `request_deposit`, `cancel_deposit`, `claim_deposit`, `request_redeem`, `cancel_redeem`, `claim_redeem`, `fund`, `activate_wind_down`, `finalize_wind_down_round`, `claim_wind_down`. | Operations are strictly constrained by contract state, timing, and solvency guards. | + +### Replacing a Compromised Attester + +Pause first, so no report lands while the roles change, and lift the pause last: + +1. Pause the NAV: the oracle's guardian calls `pause_nav`, or its admin calls `set_nav_paused(true)`. The oracle refuses every report and the vault prices nothing; sealed epochs reopen to cancellation. +2. Revoke the compromised attester: `revoke_role(attester, "attester", admin)` on the oracle. It is instant, during setup and after it. +3. Grant a replacement. During the oracle's setup, `grant_role` is instant. After it, propose a `GrantRole` change and apply it once the vault's delay has run, plus any time the vault spends paused. The NAV stays paused that whole time, at least seven days. +4. Clear the stored report: `clear_latest` (admin; only while the NAV is paused). Left in place, it is valid again once the pause lifts, and anyone can price with it any epoch sealed before it was accepted, until it expires. Skip this only if you know the report predates the compromise. The next report then has no earlier price to cap its move: only `min_answer` and `max_answer` bound it. +5. Lift the NAV pause: `set_nav_paused(false)` (admin). +6. The new attester posts a report. Pricing resumes: an epoch can be priced once a report accepted after its seal is valid. + +### When the Manager or the Attester Stops + +Pricing needs both roles, and nothing on chain replaces them on its own. The kit assumes they keep working; until then, holders keep these ways out: + +- **The manager stops sealing.** Requests in the open epoch stay pending, and each holder can still cancel: a deposit comes back as cash, a redemption as shares. Governance grants a new manager; after setup that waits for the vault's timelock, at least seven days, plus any time the vault spends paused. +- **The attester stops reporting.** An epoch sealed after the last report has no readable price, so its requests stay cancellable, and claims already priced still pay. Governance revokes the attester on the oracle at once and grants a replacement, which waits for the timelock after setup, as in steps 2 and 3 above. +- **Neither comes back.** No holder can exit at a price. Governance can propose a wind-down; once its delay has run, anyone activates it, and its rounds pay every holder pro rata from the cash the vault holds and whatever the custodian returns through `fund`, with no price needed. + +Sealing is meant to become permissionless once a minimum epoch duration bounds it (design doc §8.4). --- @@ -218,7 +247,7 @@ Strata distributes control across five native Stellar multisig authorities and p | **`AsyncVault`** | `request_deposit` | Investor (Caller) | `holder.require_auth()`; refused until setup finishes (`SetupOpen`) | Yes (halted by pause) | | **`AsyncVault`** | `cancel_deposit` | Investor (Caller) | `holder.require_auth()` | No (open during pause) | | **`AsyncVault`** | `claim_deposit` | Investor (Caller) | `holder.require_auth()` | No (open during pause) | -| **`AsyncVault`** | `request_redeem` | Investor (Caller) | `holder.require_auth()` | No (always open) | +| **`AsyncVault`** | `request_redeem` | Investor (Caller) | `holder.require_auth()` | No (open during pause; refused in a wind-down) | | **`AsyncVault`** | `cancel_redeem` | Investor (Caller) | `holder.require_auth()` | No (open during pause) | | **`AsyncVault`** | `claim_redeem` | Investor (Caller) | `holder.require_auth()` | No (always open) | | **`AsyncVault`** | `close_epoch` | Manager | `#[only_role(caller, "manager")]` | No | @@ -237,7 +266,7 @@ Strata distributes control across five native Stellar multisig authorities and p | **`AsyncVault`** | `finalize_wind_down_round` | Permissionless | None | No | | **`AsyncVault`** | `claim_wind_down` | Shareholder (Caller) | `holder.require_auth()` | No | | **`AsyncVault`** | `deploy_to_custodian` | Treasury | `#[only_role(caller, "treasury")]` | No (open during pause) | -| **`AsyncVault`** | `fund` | Treasury | None (caller transfers funds) | No | +| **`AsyncVault`** | `fund` | Anyone: the custodian, treasury or a backer | `caller.require_auth()`; the caller pays | No | | **`AsyncVault`** | `propose_change` | Governance | `#[only_admin]`; after setup | No | | **`AsyncVault`** | `cancel_change` | Governance | `#[only_admin]` | No | | **`AsyncVault`** | `apply_change` | Governance | `#[only_admin]` | Refused if paused or winding down | @@ -484,6 +513,10 @@ A feed that implements `OracleFeed` can replace `nav-oracle`; it must report `Stale` once its report outlives `freshness_duration`, and `attested_at` must be the ledger time the report was accepted, not a time the reporter supplies. +The testnet identity verifier sets one address per call (`allow`), emits no +event and offers no paged listing. These are limits of the stub: a production +identity registry replaces it. + Two more traits connect the share token, the oracle and the kit's compliance contract to the vault's timelock. The vault implements `ClockSource` (`crates/strata-timelock`), and the three read its `timelock_clock` to time their own diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index 6fabcb42..f5b0e6a6 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -76,7 +76,7 @@ or legal solution, and not a vault for on-chain RWA tokens. | Actor | On-chain authority | Does | |---|---|---| | Investor (LP) | None (allowlisted address) | Requests deposits and redemptions, claims shares or cash, cancels pending requests, views position | -| Valuation reporter | attestation | Attests the share price with a proof reference, under on-chain guardrails | +| Valuation reporter | attestation | Attests the share price 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; legal-order transfer and recovery, each co-signed by governance) | @@ -157,9 +157,11 @@ For the exhaustive access control matrix, dual `manager` role distinction, and e off-chain from the deployed value and the vault's public figures under a documented methodology. The contract validates, stores and exposes it. Every on-chain operation preserves that price by construction: pricing a deposit - releases escrow into the reserve and mints shares in proportion, pricing a - redemption burns shares and fixes the matching liability, and a custodian - transfer moves value between pockets without changing the total. + releases escrow into the reserve and owes shares in proportion, pricing a + redemption fixes the cash owed for its escrowed shares, and a custodian + transfer moves value between pockets without changing the total. Shares are + minted and burned only when each claim runs; the economic supply counts owed + shares from pricing and leaves escrowed shares out from the request. 5. **Payable claims always pay.** Neither the guardian pause, a delisting, nor a stale valuation can block the payment of an already-priced, funded cash @@ -222,7 +224,7 @@ flowchart TB LP -->|"requests · claims"| V ADM -->|"admin operations"| AUTH - OR ---|"attested value + proof ref ↑"| VS + OR ---|"attested value ↑"| VS IVC ---|"verified addresses ↑"| KYC SAC ---|"reserve in / out"| TR TR -->|"deploy_to_custodian"| CU @@ -280,14 +282,14 @@ under the [Admin panel](#admin-panel) table in §8.8. Both interfaces are backend-free: they read contract state and build transactions that the authority multisigs sign. The chain never sees KYC data or valuation methodology, only their outputs: an allowlisted address, an attested -number with a proof reference. +number. ### 8.3 Components and authorities | 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 | +| **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 | | **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 | | **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 | @@ -306,6 +308,10 @@ it can be priced, and every request in the epoch waits the same. Pricing it read the oracle and fixes one share price for every request it holds. Pricing is permissionless and refuses a feed that is not valid, so a sealed epoch waits rather than settling at a stale price, and no investor can choose their price. +Claims round down one by one, so together they never pay more than pricing +promised. When an epoch's last request settles, the remainder leaves +`pending_mint_shares` and `committed` and stays in the reserve as fund money +(`EpochReconciled` says how much). Sealing is gated on the manager role today. It is meant to become permissionless once a minimum epoch duration bounds it; until then, whoever seals chooses the @@ -316,7 +322,7 @@ batch boundary, though not the price it receives. - Request: moves the deposit asset into escrow; at most one active request per holder. The vault first asks the share token whether the address is frozen and asks the token's identity verifier, and refuses a frozen address or one that could not receive the shares, before any cash moves; the token checks again when it mints at claim time. - Pricing: the escrow leaves the cancellable bucket, the share quantity is set at the epoch's price, and the economic supply liability is recorded. To avoid unbounded loops that exceed Soroban transaction resource limits, physical share minting occurs lazily when each investor executes `claim_deposit`. - Cancellation: atomic, and returns the escrowed asset in full. Open while the epoch is open. Once sealed, a readable price closes it, even while the notice still runs: an investor who can see the price cannot decline it. If the vault can no longer price the epoch — the report expires, the NAV is paused, or the vault is paused — it opens again, so escrow is never stuck behind a stalled feed. The epoch is then priced at the latest valid report at fulfil time. Cancellation also remains open during an active wind-down, allowing pending depositors to recover their escrowed assets when pricing is closed. A frozen depositor's cancellation is held until the freeze is lifted, in a wind-down too. -- Share claim: verifies the receiver and delivers the shares via `mint`. A receiver off the allowlist gets no shares: they join the holder's redemption in the open epoch and are paid in cash at that epoch's price; in a wind-down they join the holder's wind-down entitlement. A frozen receiver is held until the freeze is lifted. No nominal refund exists after pricing. +- Share claim: verifies the receiver and delivers the shares via `mint`. A receiver off the allowlist gets no shares: they join the holder's redemption in the open epoch and are paid in cash at that epoch's price; in a wind-down they join the holder's wind-down entitlement. A frozen receiver is held until the freeze is lifted. The only refund after pricing is for a deposit worth less than one share base unit, which mints nothing: its asset comes back at claim, from the free reserve. Pricing does not set that refund aside, because it sees only the epoch's totals and reserving it would mean visiting every request for less than one share base unit each. Treasury can deploy that cash, or a wind-down round credit it, before the claim, which then waits until the free reserve covers it again. #### Redemption @@ -334,7 +340,7 @@ sequenceDiagram I->>V: request_deposit(amount), asset to escrow Note over V: pending, cancellable O->>V: attestation - Note over V: priced: shares minted and held,
cancellation closed + Note over V: priced: shares owed, minted at claim,
cancellation closed I->>V: claim, re-checks allowlist V-->>I: shares delivered ``` @@ -347,7 +353,7 @@ sequenceDiagram participant T as Treasury authority I->>V: request_redeem(shares), shares to escrow O->>V: attestation - Note over V: priced: shares burned,
cash liability fixed + Note over V: priced: cash liability fixed,
escrowed shares burned at claim alt liquid reserve covers the claim I->>V: claim V-->>I: deposit asset paid @@ -361,9 +367,10 @@ sequenceDiagram ### 8.5 Valuation and accounting -The NAV is a permissioned attestation of the share price, published with a proof -reference. The reporter computes it off-chain under a published methodology. The -contract checks bounds, cooldown and the deviation cap, then stores the price +The NAV is a permissioned attestation of the share price. The reporter computes +it off-chain under a published methodology. A report carries the price and the +time until which the reporter vouches for it; it carries no proof reference yet. +The contract checks bounds, cooldown and the deviation cap, then stores the price and exposes it with the liquidity figures it owns: ```text @@ -375,17 +382,27 @@ uncovered = max(committed - liquid_reserve, 0) `wind_down_owed` is what the wind-down rounds have credited to holders and they have not claimed yet. It is zero outside a wind-down. -**Attestation guardrails:** the reporter is a multisig, never a single key. Each -attestation carries the share price and a proof reference; attestations are -ordered by their acceptance time on the ledger. The price must stay within -configured bounds, a minimum cooldown bounds frequency, and the deviation cap is -directional: the upward bound is mandatory and non-zero, the downward bound is -required too, and setting it to 100% lets a loss of any size land in one -attestation. A report's own expiry can shorten its life but never extend it past -the freshness window, and no report is accepted while the NAV is paused: -unpausing it is a deliberate act before reporting resumes. An epoch is never -priced against a valuation accepted before it closed, and the oracle's cooldown -therefore bounds how soon a closed epoch can be priced. +The vault also reports `total_economic_supply`: shares held outside the vault +plus shares priced for deposits and not yet minted. It leaves out the shares +escrowed by a redemption that is not priced yet, although the next price still +applies to them. The deposit cap limits deposited capital: what the vault holds +plus `net_deployed` (sent to the custodian less funded back), less `committed`. +It counts money in and out, not the vault's value. A deposit request adds what +it brings, and a priced redemption takes off what it pays. A gain or a loss at +the custodian does not move it. Once leavers are paid a gain, the figure can go +negative, and the cap then admits more than its own amount. After a loss it +stays above the vault's value, and the cap refuses deposits sooner. + +**Attestation guardrails:** the reporter is a multisig, never a single key. +Attestations are ordered by their acceptance time on the ledger. The price must +stay within configured bounds, a minimum cooldown bounds frequency, and the +deviation cap is directional: the upward bound is mandatory and non-zero, the +downward bound is required too, and setting it to 100% lets a loss of any size +land in one attestation. A report's own expiry can shorten its life but never +extend it past the freshness window, and no report is accepted while the NAV is +paused: unpausing it is a deliberate act before reporting resumes. An epoch is +never priced against a valuation accepted before it closed, and the oracle's +cooldown therefore bounds how soon a closed epoch can be priced. **Freshness and pause:** each attestation opens a validity window; when it lapses the feed is stale and new requests stop being priced. The guardian can @@ -406,9 +423,10 @@ pricing, share and cash claims, mint, burn and forced transfer are unaffected. It is a trading restriction, not an emergency stop for the vault, and only governance lifts it. -**Integrator surface:** the oracle exposes the attested share price directly, as -the latest report with its proof reference and acceptance time (`nav_per_share`). -The SEP-40 feed named in section 4 is an adapter over that surface scheduled for +**Integrator surface:** the oracle exposes the attested share price directly: +the latest report with its acceptance time and expiry (`latest`), the price +alone (`nav_per_share`) and the acceptance time alone (`attested_at`). The +SEP-40 feed named in section 4 is an adapter over that surface scheduled for Tranche 3 and is not implemented yet; integrators read the oracle's own interface today. @@ -436,8 +454,11 @@ interface today. - The custodian is a genesis-configured slot; only governance can rotate it. Transfers to the custodian move free reserve only, only to that address, and only while nothing is uncovered. -- Transfers from the custodian are always open and credit only assets actually - received. +- Transfers from the custodian are always open: `fund`, which anyone may call, + credits only the assets it receives and lowers `net_deployed` by them. The + custodian repays through `fund`. A plain transfer to the vault also lands in + the reserve but leaves `net_deployed` as it was, so from then on the same + money counts twice toward the deposit cap. - The exposed figures (share price, liquid reserve, committed, uncovered) make reserve coverage legible to investors and integrators. - Closing a vault is a dedicated, announced state. Governance proposes a @@ -455,10 +476,11 @@ interface today. joins the next round's pot. - A wind-down distributes what is on chain. The vault cannot compel the custodian to return capital; recovering the rest is off-chain work, and nothing here is a - guarantee that it will be recovered. Two further limits are on the record: a + guarantee that it will be recovered. Three further limits are on the record: a holder who never surrenders their shares is never paid, because no entrypoint may iterate over holders; a refund owed to a deposit priced at zero shares - is not tracked as a cash liability, so a round can credit the assets it needed; + is not tracked as a cash liability, so a round can credit the assets it needed + (§8.4); and symmetrically, a redemption priced at zero assets pays nothing, and once the wind-down is active its claim burns the escrowed shares instead of returning them. @@ -605,8 +627,8 @@ 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, 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. | +| 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. | | 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 | diff --git a/environments.toml b/environments.toml index 7b115313..28b5acee 100644 --- a/environments.toml +++ b/environments.toml @@ -57,7 +57,11 @@ constructor_args = '--asset share_token --share_token share_token --oracle nav_o # Setup cannot finish from this file either. The vault's `finish_setup` checks # the token, oracle and compliance bindings, the manager role and the # allowlist, which only the harness deploy wires, and that the token has no -# shares outstanding. A deploy made only from this file leaves the compliance +# shares outstanding. It also checks that the asset has the token's decimals, +# that the asset, the token, the oracle and the vault are four different +# addresses (here the asset is the share token, so this one fails too), that +# the oracle answers as a feed, and that a custodian other than the vault is +# set. A deploy made only from this file leaves the compliance # contract's, the token's, the oracle's and the vault's setup open, so its # vault refuses deposits with `SetupOpen`. The harness deploy closes all four.