From 3180dd5beae2afe76b528b8b3225fc2e5e8340b2 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:51:47 -0300 Subject: [PATCH 01/17] docs: open each contract with its design notes --- contracts/async-vault/src/lib.rs | 68 ++++++++++++++++++++++++++ contracts/compliance/src/lib.rs | 23 +++++++++ contracts/identity-verifier/src/lib.rs | 23 +++++++++ contracts/nav-oracle/src/lib.rs | 44 +++++++++++++++++ contracts/share-token/src/lib.rs | 38 ++++++++++++++ 5 files changed, 196 insertions(+) diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 71045251..362e460d 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -1,3 +1,71 @@ +//! # 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.** Sealing an epoch needs the +//! `manager` role. Pricing it is open to anyone once its notice has run and +//! a valid report accepted after the seal exists, so the price is the +//! oracle's, not the caller's. The cost: a manager that stops sealing, or an +//! attester that stops reporting, stalls every request until it is +//! replaced, and after setup a replacement waits for the timelock. +//! - **Pricing records totals; claims mint and burn.** Pricing costs the same +//! whatever the number of requests: it fixes the price and the epoch's +//! totals, and each claim then mints or burns its own shares. The cost: the +//! token's supply is not the economic supply until every claim is made, and +//! since each claim rounds down, the epoch's remainder stays in +//! `pending_mint_shares` and `committed` until its last request settles. A +//! request that can never settle keeps that remainder there. +//! - **A readable price closes cancellation.** Cancellation closes once a +//! valid report accepted after the seal exists, even while the notice runs, +//! and reopens whenever the vault could not price the epoch now: the feed +//! goes stale, the NAV is paused or the vault is paused. Nobody declines a +//! price they have seen, and no escrow waits on a stalled feed. The cost: a +//! report that lapses lets requests leave a sealed epoch late, so +//! `freshness_duration` should outlast the notice. +//! - **Priced before paid.** A redemption is priced whether or not the reserve +//! holds the cash, and its claim fails with `ClaimNotCovered` until the +//! liquid reserve covers it. Covered claims pay in any order, and nothing +//! goes to the custodian while anything is uncovered. The cost: a priced +//! exit can wait on treasury, and a later, smaller claim can be paid before +//! an earlier, larger one. +//! - **A refund below one share is not reserved.** A deposit worth less than +//! one share base unit mints nothing and is refunded at claim, from the free +//! reserve. Pricing sees only the epoch's totals, and setting these refunds +//! aside would mean visiting every request, for less than one share base +//! unit each. The cost: treasury can deploy that cash, or a wind-down round +//! credit it, before the claim, which then fails with `ClaimNotCovered` +//! until the free reserve covers it again. +//! - **Checked totals.** Every running total of assets or shares changes +//! through checked arithmetic and fails by name: `AmountTooLarge` when it +//! would overflow, `AccountingMismatch` when it would go below zero. None +//! wraps or clamps. The cost: if the books ever disagree, the call that finds +//! it fails instead of carrying on with a clamped figure. +//! - **A freeze holds every exit.** A frozen address cannot request a deposit, +//! and its claims, cancellations, redemption requests and wind-down claims +//! fail with `Frozen` until the freeze lifts. Compliance can delay a payout +//! this way, never redirect it. The cost: even a covered cash claim waits for +//! the unfreeze. +//! - **A delisted holder leaves in cash.** A holder delisted after pricing gets +//! no shares. Outside a wind-down the priced shares are minted into the +//! vault's escrow and join the holder's redemption in the open epoch; in a +//! wind-down they are credited to the holder's entitlement. No share reaches +//! an unlisted address, and the holder keeps a way out. The cost: the holder +//! waits another epoch and takes its price, and the conversion waits while +//! the vault's own address is delisted. +//! - **Setup, then the timelock.** Admin changes are instant during setup, +//! which takes no deposit and ends only once `finish_setup` finds every +//! wiring check true; it never reopens. After it, the changes that can move +//! value or shorten a wait go through the timelock. The cost: a mistake +//! found after setup takes a full delay, at least seven days, to fix. +//! - **Wind-down claims run the compliance hooks.** A wind-down claim that +//! surrenders shares burns them through the token, which runs the compliance +//! contract's `destroyed` modules. The cost: a module that starts failing +//! during a wind-down blocks those claims, and it cannot be removed then, +//! because removing a module waits for the timelock and no timelocked change +//! lands during a wind-down. + #![no_std] mod deposit; diff --git a/contracts/compliance/src/lib.rs b/contracts/compliance/src/lib.rs index 63b19938..c5c04a4d 100644 --- a/contracts/compliance/src/lib.rs +++ b/contracts/compliance/src/lib.rs @@ -1,5 +1,28 @@ +//! # 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 the token +//! runs passes; the allowlist lives in the identity verifier, which the token +//! asks separately. The cost: any rule beyond the allowlist needs a module or +//! another compliance contract. +//! - **Changes wait for the vault's timelock.** After setup, adding or removing +//! a module, binding or unbinding a token and handing over the admin wait for +//! the vault's delay; a paused vault holds them and a wind-down refuses them. +//! The cost: a faulty module takes a full delay to remove, and during a +//! wind-down it cannot be removed at all, so a module that fails on a burn +//! blocks every claim that burns shares. +//! - **An unbound token fails.** The hooks the token calls after every mint, +//! transfer and burn refuse a token that is not bound. The cost: unbinding +//! the share token stops all three, the vault's claims included. +//! - **The operator is not read.** OpenZeppelin's module and binding calls take +//! an `operator`; the stub ignores it and asks the admin to sign. The cost: +//! the admin cannot delegate those calls to an operator. +//! - **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..5e069f92 100644 --- a/contracts/identity-verifier/src/lib.rs +++ b/contracts/identity-verifier/src/lib.rs @@ -1,5 +1,28 @@ +//! # 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.** An account is allowed or not, and one never +//! set is refused. There are no claims, topics or issuers: +//! `set_claim_topics_and_issuers` does nothing. 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, during setup and +//! after it, with no timelock. The cost: a stolen admin key can list or +//! delist any account at once; a delisted holder still leaves through the +//! vault's cash path. +//! - **A fixed admin, no upgrade.** There is no admin hand-over and no upgrade +//! path. The cost: rotating the admin key or fixing the code means a new +//! verifier and a token `IdentityVerifier` change, which waits for the +//! vault's timelock after setup. +//! - **No recovery account.** `recovery_target` always answers `None`, so the +//! token's `recover_balance` always fails. The cost: a lost wallet's shares +//! move with `legal_transfer` instead, signed by compliance and the admin. +//! - **OpenZeppelin's error code.** A refusal is +//! `RWAError::IdentityVerificationFailed` (304); the stub has no error enum. +//! The cost: that 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..acd0ccfc 100644 --- a/contracts/nav-oracle/src/lib.rs +++ b/contracts/nav-oracle/src/lib.rs @@ -1,3 +1,47 @@ +//! # 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.** The oracle stores the time it accepts a +//! report and ignores the reporter's own timestamp. The report expires at the +//! earlier of the reporter's `expires_at` and that time plus +//! `freshness_duration`, so a reporter can shorten a report's life but never +//! extend it. The cost: the vault's rule that a price must come after an +//! epoch's seal compares acceptance times, not valuation dates. +//! - **No report while the NAV is paused.** The guardian or the admin pauses +//! the NAV, and only the admin lifts the pause, so reporting resumes by a +//! deliberate act. The cost: while the pause lasts the vault prices nothing, +//! and sealed epochs reopen to cancellation. +//! - **Tightening waits too.** After setup every config change waits for the +//! vault's timelock; only a shorter `freshness_duration` is instant. A +//! tighter fall cap can hold the price above a real loss, and a narrower band +//! can stop pricing, so tightening moves value too. The cost: an urgent +//! change to the limits takes the full delay; the NAV pause is the instant +//! stop. +//! - **The band applies on read.** A stored report outside the current band +//! reads as stale. The cost: narrowing the band can stop pricing the moment +//! the change lands, until a report inside the new band arrives. +//! - **Both moves are capped.** A report may move from the last one by at most +//! `max_up_bps` up and `max_down_bps` down, and both caps are required. A +//! fall cap of 10,000 bps lets a loss of any size land at once, leaving +//! `min_answer` as the only floor. The cost of a lower fall cap: a real loss +//! takes several reports, at least a cooldown apart, and redemptions priced +//! meanwhile are paid above value. +//! - **Revoking is instant, granting waits.** Taking the `attester` or +//! `guardian` role away moves no value and stays instant; after setup a grant +//! 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` removes the stored report +//! only while the NAV is paused, so the admin clears and the attester's next +//! report is a separate act. The cost: that report has no earlier price to +//! cap its move; only the band bounds it. +//! - **One report per cooldown.** The cooldown is at least one second and at +//! most `freshness_duration`: never two reports in one ledger, and the next +//! report is always allowed before the freshness window ends. The cost: an +//! epoch sealed just after a report waits up to a cooldown for the next one. + #![no_std] mod extend; diff --git a/contracts/share-token/src/lib.rs b/contracts/share-token/src/lib.rs index a9f55cdd..24e78722 100644 --- a/contracts/share-token/src/lib.rs +++ b/contracts/share-token/src/lib.rs @@ -1,3 +1,41 @@ +//! # Design +//! +//! The vault's share token: OpenZeppelin's RWA token, which asks the identity +//! verifier about every receiver and the compliance contract about every move. +//! The kit adds its roles, its guards and the vault's timelock. +//! +//! - **No clock of its own.** After setup, upgrades, grants, revoking +//! `manager`, admin hand-overs and compliance or verifier swaps wait for the +//! vault's timelock. Revoking `compliance` and the transfer pause stay +//! instant. The cost: a paused vault holds every token change, and none +//! lands during a wind-down. +//! - **The vault is the only manager.** `mint`, `burn` and `forced_transfer` +//! need `manager`, which the vault must hold alone for its setup to finish. +//! `forced_transfer` skips the pause, freezes, the identity check and the +//! compliance rules' check, so the vault checks the receiver itself before it +//! returns shares. The cost: a `manager` can move any holder's shares without +//! their signature, so after setup granting or revoking it waits for the +//! timelock. +//! - **Two signers move a holder's shares.** `legal_transfer` and +//! `recover_balance` need a `compliance` holder and the admin to sign as two +//! different accounts, and the shares land only on an allowlisted account. +//! The cost: a legal order needs both signers. A legal transfer of zero +//! shares is accepted: it moves nothing but publishes its order reference. +//! - **The vault's address is off limits.** Once the token is bound to the +//! vault, its address cannot be frozen, in full or in part, and no legal +//! transfer or recovery moves shares into or out of it: its balance is other +//! holders' escrow. The cost: an order cannot reach shares escrowed in the +//! vault; compliance freezes the holder instead, which holds that holder's +//! exits. +//! - **The pause halts trading only.** The transfer pause is the admin's, not +//! the guardian's. It stops `transfer` and `transfer_from`; mints, burns, +//! escrow moves and legal transfers go on. The cost: it is not an emergency +//! stop for the vault. +//! - **A compliance swap is not checked.** Swapping the compliance contract +//! does not check that the new contract lists this token. The cost: if it +//! does not, every mint, transfer and burn fails until it binds the token, +//! and the vault's `health()` reports `compliance_bound` false. + #![no_std] mod contract; mod error; From e34e5b980faf705cddbcc8ed88d9914ed11cfeaf Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:51:57 -0300 Subject: [PATCH 02/17] docs: open the shared crates with design notes --- crates/strata-bindings/src/lib.rs | 20 ++++++++++++++++++ crates/strata-storage/src/lib.rs | 22 ++++++++++++++++++++ crates/strata-timelock/src/lib.rs | 34 +++++++++++++++++++++++++++++-- 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/crates/strata-bindings/src/lib.rs b/crates/strata-bindings/src/lib.rs index 98f582c1..706fac33 100644 --- a/crates/strata-bindings/src/lib.rs +++ b/crates/strata-bindings/src/lib.rs @@ -1,3 +1,23 @@ +//! # Design +//! +//! The interfaces the kit's contracts share, and each contract's block of +//! error codes. +//! +//! - **The compiler checks the feed.** The vault prices from `OracleFeed`, +//! which the kit's oracle and any feed an adopter brings both implement. The +//! cost: a feed must answer the trait's four calls as written; any other +//! shape needs an adapter. +//! - **Bound contracts report their binding.** A contract that runs on a +//! vault's timelock answers `VaultBinding`, and the vault's `finish_setup` +//! reads it. The cost: a compliance contract that does not answer it is +//! accepted as an external module, so its own governance is a trust +//! assumption. +//! - **A block of codes per contract.** Each kit contract owns a block of error +//! codes, and `error_enum!` fails the build if a code leaves its block or two +//! blocks overlap, so a code inside the blocks names the contract that raised +//! it. The cost: a code is never reused, so a removed variant leaves its +//! number retired. + #![no_std] mod errors; diff --git a/crates/strata-storage/src/lib.rs b/crates/strata-storage/src/lib.rs index 6ad5f337..0b11996a 100644 --- a/crates/strata-storage/src/lib.rs +++ b/crates/strata-storage/src/lib.rs @@ -1,3 +1,25 @@ +//! # 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 contract instance +//! included, goes back to 30 days once it falls a day short of that. The +//! cost: an entry in daily use renews about once a day, for about a day of +//! rent, and one nobody reads archives when its lifetime runs out. +//! - **Extended in the call that creates it.** A write asks for the renewal +//! too, so a new entry lives at least its target from the call that creates +//! it. The cost: that call pays for the target, or for the network's minimum +//! when that is longer. +//! - **The same lengths on every network.** They are fixed in code, and each +//! network applies its own minimum lifetime on top, so the Wasm tested on +//! testnet is the Wasm that ships. The cost: no network gets lengths of its +//! own, and changing them takes an upgrade. +//! - **No cap per call.** An extension always reaches the full target. A cap +//! would let rarely read entries archive sooner, and a restore on mainnet +//! pays at least 120 days of rent. The cost: the first call after a quiet +//! period pays to renew everything it touches. + #![no_std] use core::fmt::Debug; diff --git a/crates/strata-timelock/src/lib.rs b/crates/strata-timelock/src/lib.rs index eb3d3139..9f117357 100644 --- a/crates/strata-timelock/src/lib.rs +++ b/crates/strata-timelock/src/lib.rs @@ -1,6 +1,36 @@ +//! # 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 share token, the oracle and the kit's +//! compliance contract bind to the vault once, during setup, and read its +//! clock on every proposal and every apply. One pause and one wind-down then +//! hold every waiting change in the kit. The cost: while the vault is paused +//! no contract in the kit can apply a change, and a contract bound to the +//! wrong vault stays bound. +//! - **A setup window first.** Changes are instant until setup finishes, and +//! nothing is queued before it; setup closes once and never reopens. The +//! cost: a mistake found after setup takes a full delay to fix. +//! - **One queue per contract.** Every kind of change waits in the same queue, +//! at most `MAX_PENDING` at a time, and an id is never handed out twice. The +//! cost: a full queue refuses proposals until a change lands or is cancelled. +//! - **The delay is an announcement period.** A change keeps the delay it was +//! proposed with, its eta moves back by every second the vault spends paused, +//! and nothing lands while the vault is paused. The delay promises notice, +//! not an exit: leaving before a change lands also needs a sealed epoch, a +//! price and cash. The cost: a long pause delays every fix. +//! - **Never shorter than the notice.** A change whose delay is shorter than +//! the notice when it applies never lands. The cost: raising the notice can +//! strand changes already waiting; they must be proposed again. +//! - **Nothing lands in a wind-down.** Once a wind-down starts nothing is +//! proposed or applied, and changes already waiting never land. 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] From 77b236e716a4a53ae0149da34ce17739e5f1e0fc Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:55:58 -0300 Subject: [PATCH 03/17] docs: correct the attester and manager docs --- contracts/async-vault/src/lib.rs | 7 +--- contracts/async-vault/src/roles.rs | 2 +- docs/AUTHORIZATION_MATRIX.md | 4 +-- docs/kit-spec.md | 16 +++++++-- docs/strata-product-and-architecture.md | 43 +++++++++++++------------ 5 files changed, 40 insertions(+), 32 deletions(-) diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 362e460d..534b9acc 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -169,12 +169,7 @@ 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. + // `roles` holds the five authorities and the manager; the attester is not read. 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..6f9c4302 100644 --- a/contracts/async-vault/src/roles.rs +++ b/contracts/async-vault/src/roles.rs @@ -1,6 +1,6 @@ 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. diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index b1174021..a74c0ae6 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. diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 1d286016..76f08fd3 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -199,11 +199,23 @@ 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. | +| **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. | +| **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`, `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. If the stored report is wrong, call `clear_latest` (admin; only while the NAV is paused). 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. An epoch is priced once a report accepted after its seal is valid. + --- ### Comprehensive Entrypoint Permission Matrix diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index 6fabcb42..6d8b50fb 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) | @@ -222,7 +222,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,7 +280,7 @@ 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 @@ -361,9 +361,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 +376,16 @@ 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. +**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 +406,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. @@ -605,7 +606,7 @@ public surface given the vault address. | Boundary | Risk | Mitigation | | ---------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Attestation authority | Wrong or compromised reports misprice requests | Multisig reporter; the deviation cap bounds a single report in both directions, and a downward cap of 100% leaves `min_answer` as the only floor; the cooldown bounds frequency; a sustained sequence of biased reports within the cap remains possible, is bounded in speed, and is the monitoring plan's primary alert, with the guardian pause as the reactive control. Residual risk: value transfer between entry and exit cohorts | +| 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 if it is wrong, 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, 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. | | 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 | From 39b3cddf4800873393cca0fb799de8d285949eeb Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:57:18 -0300 Subject: [PATCH 04/17] docs: call the timelock delay an announcement --- crates/strata-timelock/src/clock.rs | 3 ++- crates/strata-timelock/src/delay.rs | 4 ++-- docs/strata-product-and-architecture.md | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) 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/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index 6d8b50fb..af8ccb70 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -607,7 +607,7 @@ public surface given the vault address. | Boundary | Risk | Mitigation | | ---------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Attestation authority | Wrong or compromised reports misprice requests | Multisig reporter; the deviation cap bounds a single report in both directions, and a downward cap of 100% leaves `min_answer` as the only floor; the cooldown bounds frequency; a sustained sequence of biased reports within the cap remains possible, is bounded in speed, and is the monitoring plan's primary alert, with the guardian pause as the reactive control. Residual risk: value transfer between entry and exit cohorts. A compromised attester is replaced in this order: pause the NAV, revoke the attester (instant), grant a replacement (through the timelock after setup, so the NAV stays paused that long), clear the stored report if it is wrong, 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, 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. | +| 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 | From bc7915b23fba392f1758017e97f35e8d4b6ba535 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:59:32 -0300 Subject: [PATCH 05/17] docs: state what refunds and capital figures do --- contracts/async-vault/src/roles.rs | 2 +- docs/COMPLIANCE_MATRIX.md | 2 +- docs/kit-spec.md | 15 +++++++++++---- docs/strata-product-and-architecture.md | 21 ++++++++++++++++----- 4 files changed, 29 insertions(+), 11 deletions(-) diff --git a/contracts/async-vault/src/roles.rs b/contracts/async-vault/src/roles.rs index 6f9c4302..3e095562 100644 --- a/contracts/async-vault/src/roles.rs +++ b/contracts/async-vault/src/roles.rs @@ -3,7 +3,7 @@ use soroban_sdk::{contracttype, symbol_short, Address, Symbol}; /// 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/docs/COMPLIANCE_MATRIX.md b/docs/COMPLIANCE_MATRIX.md index 9e01af2b..3a318040 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. diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 76f08fd3..956cb1bd 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -77,6 +77,7 @@ 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. @@ -158,7 +159,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. | | **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,6 +187,12 @@ The `async_vault` constructor strictly verifies pairwise separation across autho `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, including those of a redemption that is not priced yet, which the next price still applies to and a cancellation can return (`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 asset units at cost: a gain or a loss at the custodian never moves it, even once funded back. +- `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 @@ -198,12 +205,12 @@ 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. | +| **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 (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`, `activate_wind_down`, `finalize_wind_down_round`, `claim_wind_down`. | Operations are strictly constrained by contract state, timing, and solvency guards. | +| **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 @@ -244,7 +251,7 @@ Pause first, so no report lands while the roles change, and lift the pause last: | **`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 | diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index af8ccb70..6e39d863 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -316,7 +316,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 @@ -376,6 +376,13 @@ 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. +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`, +in asset units at cost, so a gain or a loss at the custodian never moves it. + **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 @@ -437,8 +444,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 @@ -456,10 +466,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. From 72c15a59cef1b3522aeeb0a505371eca152b448d Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 06:05:26 -0300 Subject: [PATCH 06/17] docs: tighten the kit spec wording --- docs/kit-spec.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 956cb1bd..c4caa665 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -189,7 +189,7 @@ The `async_vault` constructor strictly verifies pairwise separation across autho ### 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, including those of a redemption that is not priced yet, which the next price still applies to and a cancellation can return (`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. +- `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 asset units at cost: a gain or a loss at the custodian never moves it, even once funded back. - `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. @@ -221,7 +221,7 @@ Pause first, so no report lands while the roles change, and lift the pause last: 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. If the stored report is wrong, call `clear_latest` (admin; only while the NAV is paused). 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. An epoch is priced once a report accepted after its seal is valid. +6. The new attester posts a report. Pricing resumes: an epoch can be priced once a report accepted after its seal is valid. --- From 679f592e437590cf7cd45e9ce7b5b7b62abd9639 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 07:01:09 -0300 Subject: [PATCH 07/17] docs: tighten the design notes --- contracts/async-vault/src/lib.rs | 95 +++++++++----------------- contracts/compliance/src/lib.rs | 26 +++---- contracts/identity-verifier/src/lib.rs | 30 ++++---- contracts/nav-oracle/src/lib.rs | 57 ++++++---------- contracts/share-token/src/lib.rs | 55 ++++++--------- crates/strata-bindings/src/lib.rs | 24 +++---- crates/strata-storage/src/lib.rs | 28 ++++---- crates/strata-timelock/src/lib.rs | 42 +++++------- 8 files changed, 138 insertions(+), 219 deletions(-) diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 534b9acc..6b296b35 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -1,70 +1,39 @@ //! # 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. +//! 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.** Sealing an epoch needs the -//! `manager` role. Pricing it is open to anyone once its notice has run and -//! a valid report accepted after the seal exists, so the price is the -//! oracle's, not the caller's. The cost: a manager that stops sealing, or an -//! attester that stops reporting, stalls every request until it is -//! replaced, and after setup a replacement waits for the timelock. +//! - **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 -//! whatever the number of requests: it fixes the price and the epoch's -//! totals, and each claim then mints or burns its own shares. The cost: the -//! token's supply is not the economic supply until every claim is made, and -//! since each claim rounds down, the epoch's remainder stays in -//! `pending_mint_shares` and `committed` until its last request settles. A -//! request that can never settle keeps that remainder there. -//! - **A readable price closes cancellation.** Cancellation closes once a -//! valid report accepted after the seal exists, even while the notice runs, -//! and reopens whenever the vault could not price the epoch now: the feed -//! goes stale, the NAV is paused or the vault is paused. Nobody declines a -//! price they have seen, and no escrow waits on a stalled feed. The cost: a -//! report that lapses lets requests leave a sealed epoch late, so -//! `freshness_duration` should outlast the notice. -//! - **Priced before paid.** A redemption is priced whether or not the reserve -//! holds the cash, and its claim fails with `ClaimNotCovered` until the -//! liquid reserve covers it. Covered claims pay in any order, and nothing -//! goes to the custodian while anything is uncovered. The cost: a priced -//! exit can wait on treasury, and a later, smaller claim can be paid before -//! an earlier, larger one. -//! - **A refund below one share is not reserved.** A deposit worth less than -//! one share base unit mints nothing and is refunded at claim, from the free -//! reserve. Pricing sees only the epoch's totals, and setting these refunds -//! aside would mean visiting every request, for less than one share base -//! unit each. The cost: treasury can deploy that cash, or a wind-down round -//! credit it, before the claim, which then fails with `ClaimNotCovered` -//! until the free reserve covers it again. -//! - **Checked totals.** Every running total of assets or shares changes -//! through checked arithmetic and fails by name: `AmountTooLarge` when it -//! would overflow, `AccountingMismatch` when it would go below zero. None -//! wraps or clamps. The cost: if the books ever disagree, the call that finds -//! it fails instead of carrying on with a clamped figure. -//! - **A freeze holds every exit.** A frozen address cannot request a deposit, -//! and its claims, cancellations, redemption requests and wind-down claims -//! fail with `Frozen` until the freeze lifts. Compliance can delay a payout -//! this way, never redirect it. The cost: even a covered cash claim waits for -//! the unfreeze. -//! - **A delisted holder leaves in cash.** A holder delisted after pricing gets -//! no shares. Outside a wind-down the priced shares are minted into the -//! vault's escrow and join the holder's redemption in the open epoch; in a -//! wind-down they are credited to the holder's entitlement. No share reaches -//! an unlisted address, and the holder keeps a way out. The cost: the holder -//! waits another epoch and takes its price, and the conversion waits while -//! the vault's own address is delisted. -//! - **Setup, then the timelock.** Admin changes are instant during setup, -//! which takes no deposit and ends only once `finish_setup` finds every -//! wiring check true; it never reopens. After it, the changes that can move -//! value or shorten a wait go through the timelock. The cost: a mistake -//! found after setup takes a full delay, at least seven days, to fix. -//! - **Wind-down claims run the compliance hooks.** A wind-down claim that -//! surrenders shares burns them through the token, which runs the compliance -//! contract's `destroyed` modules. The cost: a module that starts failing -//! during a wind-down blocks those claims, and it cannot be removed then, -//! because removing a module waits for the timelock and no timelocked change -//! lands during a wind-down. +//! 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] diff --git a/contracts/compliance/src/lib.rs b/contracts/compliance/src/lib.rs index c5c04a4d..a56add4e 100644 --- a/contracts/compliance/src/lib.rs +++ b/contracts/compliance/src/lib.rs @@ -3,22 +3,18 @@ //! 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 the token -//! runs passes; the allowlist lives in the identity verifier, which the token -//! asks separately. The cost: any rule beyond the allowlist needs a module or -//! another compliance contract. -//! - **Changes wait for the vault's timelock.** After setup, adding or removing -//! a module, binding or unbinding a token and handing over the admin wait for -//! the vault's delay; a paused vault holds them and a wind-down refuses them. -//! The cost: a faulty module takes a full delay to remove, and during a -//! wind-down it cannot be removed at all, so a module that fails on a burn -//! blocks every claim that burns shares. -//! - **An unbound token fails.** The hooks the token calls after every mint, -//! transfer and burn refuse a token that is not bound. The cost: unbinding -//! the share token stops all three, the vault's claims included. +//! - **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 ignores it and asks the admin to sign. The cost: -//! the admin cannot delegate those calls to an operator. +//! 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. diff --git a/contracts/identity-verifier/src/lib.rs b/contracts/identity-verifier/src/lib.rs index 5e069f92..f535b542 100644 --- a/contracts/identity-verifier/src/lib.rs +++ b/contracts/identity-verifier/src/lib.rs @@ -3,25 +3,21 @@ //! 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.** An account is allowed or not, and one never -//! set is refused. There are no claims, topics or issuers: -//! `set_claim_topics_and_issuers` does nothing. 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, during setup and -//! after it, with no timelock. The cost: a stolen admin key can list or -//! delist any account at once; a delisted holder still leaves through the -//! vault's cash path. -//! - **A fixed admin, no upgrade.** There is no admin hand-over and no upgrade -//! path. The cost: rotating the admin key or fixing the code means a new -//! verifier and a token `IdentityVerifier` change, which waits for the -//! vault's timelock after setup. -//! - **No recovery account.** `recovery_target` always answers `None`, so the -//! token's `recover_balance` always fails. The cost: a lost wallet's shares -//! move with `legal_transfer` instead, signed by compliance and the admin. +//! - **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: that code says what failed, not which contract refused. +//! The cost: the code says what failed, not which contract refused. #![no_std] diff --git a/contracts/nav-oracle/src/lib.rs b/contracts/nav-oracle/src/lib.rs index acd0ccfc..bb130e1d 100644 --- a/contracts/nav-oracle/src/lib.rs +++ b/contracts/nav-oracle/src/lib.rs @@ -4,43 +4,30 @@ //! and the oracle keeps the latest report that passes the limits the admin //! sets. //! -//! - **The ledger dates a report.** The oracle stores the time it accepts a -//! report and ignores the reporter's own timestamp. The report expires at the -//! earlier of the reporter's `expires_at` and that time plus -//! `freshness_duration`, so a reporter can shorten a report's life but never -//! extend it. The cost: the vault's rule that a price must come after an -//! epoch's seal compares acceptance times, not valuation dates. +//! - **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 -//! the NAV, and only the admin lifts the pause, so reporting resumes by a -//! deliberate act. The cost: while the pause lasts the vault prices nothing, +//! it; only the admin lifts it. The cost: the vault prices nothing meanwhile, //! and sealed epochs reopen to cancellation. -//! - **Tightening waits too.** After setup every config change waits for the -//! vault's timelock; only a shorter `freshness_duration` is instant. A -//! tighter fall cap can hold the price above a real loss, and a narrower band -//! can stop pricing, so tightening moves value too. The cost: an urgent -//! change to the limits takes the full delay; the NAV pause is the instant -//! stop. -//! - **The band applies on read.** A stored report outside the current band -//! reads as stale. The cost: narrowing the band can stop pricing the moment -//! the change lands, until a report inside the new band arrives. -//! - **Both moves are capped.** A report may move from the last one by at most -//! `max_up_bps` up and `max_down_bps` down, and both caps are required. A -//! fall cap of 10,000 bps lets a loss of any size land at once, leaving -//! `min_answer` as the only floor. The cost of a lower fall cap: a real loss -//! takes several reports, at least a cooldown apart, and redemptions priced -//! meanwhile are paid above value. -//! - **Revoking is instant, granting waits.** Taking the `attester` or -//! `guardian` role away moves no value and stays instant; after setup a grant -//! 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` removes the stored report -//! only while the NAV is paused, so the admin clears and the attester's next -//! report is a separate act. The cost: that report has no earlier price to -//! cap its move; only the band bounds it. -//! - **One report per cooldown.** The cooldown is at least one second and at -//! most `freshness_duration`: never two reports in one ledger, and the next -//! report is always allowed before the freshness window ends. The cost: an -//! epoch sealed just after a report waits up to a cooldown for the next one. +//! - **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] diff --git a/contracts/share-token/src/lib.rs b/contracts/share-token/src/lib.rs index 24e78722..567750d3 100644 --- a/contracts/share-token/src/lib.rs +++ b/contracts/share-token/src/lib.rs @@ -1,40 +1,27 @@ //! # Design //! -//! The vault's share token: OpenZeppelin's RWA token, which asks the identity -//! verifier about every receiver and the compliance contract about every move. -//! The kit adds its roles, its guards and the vault's timelock. +//! 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, revoking -//! `manager`, admin hand-overs and compliance or verifier swaps wait for the -//! vault's timelock. Revoking `compliance` and the transfer pause stay -//! instant. The cost: a paused vault holds every token change, and none -//! lands during a wind-down. -//! - **The vault is the only manager.** `mint`, `burn` and `forced_transfer` -//! need `manager`, which the vault must hold alone for its setup to finish. -//! `forced_transfer` skips the pause, freezes, the identity check and the -//! compliance rules' check, so the vault checks the receiver itself before it -//! returns shares. The cost: a `manager` can move any holder's shares without -//! their signature, so after setup granting or revoking it waits for the -//! timelock. -//! - **Two signers move a holder's shares.** `legal_transfer` and -//! `recover_balance` need a `compliance` holder and the admin to sign as two -//! different accounts, and the shares land only on an allowlisted account. -//! The cost: a legal order needs both signers. A legal transfer of zero -//! shares is accepted: it moves nothing but publishes its order reference. -//! - **The vault's address is off limits.** Once the token is bound to the -//! vault, its address cannot be frozen, in full or in part, and no legal -//! transfer or recovery moves shares into or out of it: its balance is other -//! holders' escrow. The cost: an order cannot reach shares escrowed in the -//! vault; compliance freezes the holder instead, which holds that holder's -//! exits. -//! - **The pause halts trading only.** The transfer pause is the admin's, not -//! the guardian's. It stops `transfer` and `transfer_from`; mints, burns, -//! escrow moves and legal transfers go on. The cost: it is not an emergency -//! stop for the vault. -//! - **A compliance swap is not checked.** Swapping the compliance contract -//! does not check that the new contract lists this token. The cost: if it -//! does not, every mint, transfer and burn fails until it binds the token, -//! and the vault's `health()` reports `compliance_bound` false. +//! - **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; diff --git a/crates/strata-bindings/src/lib.rs b/crates/strata-bindings/src/lib.rs index 706fac33..a7d70730 100644 --- a/crates/strata-bindings/src/lib.rs +++ b/crates/strata-bindings/src/lib.rs @@ -1,22 +1,16 @@ //! # Design //! -//! The interfaces the kit's contracts share, and each contract's block of -//! error codes. +//! 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 feed an adopter brings both implement. The -//! cost: a feed must answer the trait's four calls as written; any other -//! shape needs an adapter. -//! - **Bound contracts report their binding.** A contract that runs on a -//! vault's timelock answers `VaultBinding`, and the vault's `finish_setup` -//! reads it. The cost: a compliance contract that does not answer it is -//! accepted as an external module, so its own governance is a trust -//! assumption. -//! - **A block of codes per contract.** Each kit contract owns a block of error -//! codes, and `error_enum!` fails the build if a code leaves its block or two -//! blocks overlap, so a code inside the blocks names the contract that raised -//! it. The cost: a code is never reused, so a removed variant leaves its -//! number retired. +//! 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] diff --git a/crates/strata-storage/src/lib.rs b/crates/strata-storage/src/lib.rs index 0b11996a..9c79aec1 100644 --- a/crates/strata-storage/src/lib.rs +++ b/crates/strata-storage/src/lib.rs @@ -3,22 +3,18 @@ //! 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 contract instance -//! included, goes back to 30 days once it falls a day short of that. The -//! cost: an entry in daily use renews about once a day, for about a day of -//! rent, and one nobody reads archives when its lifetime runs out. -//! - **Extended in the call that creates it.** A write asks for the renewal -//! too, so a new entry lives at least its target from the call that creates -//! it. The cost: that call pays for the target, or for the network's minimum -//! when that is longer. -//! - **The same lengths on every network.** They are fixed in code, and each -//! network applies its own minimum lifetime on top, so the Wasm tested on -//! testnet is the Wasm that ships. The cost: no network gets lengths of its -//! own, and changing them takes an upgrade. -//! - **No cap per call.** An extension always reaches the full target. A cap -//! would let rarely read entries archive sooner, and a restore on mainnet -//! pays at least 120 days of rent. The cost: the first call after a quiet -//! period pays to renew everything 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] diff --git a/crates/strata-timelock/src/lib.rs b/crates/strata-timelock/src/lib.rs index 9f117357..203ea026 100644 --- a/crates/strata-timelock/src/lib.rs +++ b/crates/strata-timelock/src/lib.rs @@ -5,30 +5,24 @@ //! down. That contract and every contract bound to it queue their changes //! against the same clock. //! -//! - **One clock, the vault's.** The share token, the oracle and the kit's -//! compliance contract bind to the vault once, during setup, and read its -//! clock on every proposal and every apply. One pause and one wind-down then -//! hold every waiting change in the kit. The cost: while the vault is paused -//! no contract in the kit can apply a change, and a contract bound to the -//! wrong vault stays bound. -//! - **A setup window first.** Changes are instant until setup finishes, and -//! nothing is queued before it; setup closes once and never reopens. The -//! cost: a mistake found after setup takes a full delay to fix. -//! - **One queue per contract.** Every kind of change waits in the same queue, -//! at most `MAX_PENDING` at a time, and an id is never handed out twice. The -//! cost: a full queue refuses proposals until a change lands or is cancelled. -//! - **The delay is an announcement period.** A change keeps the delay it was -//! proposed with, its eta moves back by every second the vault spends paused, -//! and nothing lands while the vault is paused. The delay promises notice, -//! not an exit: leaving before a change lands also needs a sealed epoch, a -//! price and cash. The cost: a long pause delays every fix. -//! - **Never shorter than the notice.** A change whose delay is shorter than -//! the notice when it applies never lands. The cost: raising the notice can -//! strand changes already waiting; they must be proposed again. -//! - **Nothing lands in a wind-down.** Once a wind-down starts nothing is -//! proposed or applied, and changes already waiting never land. The cost: -//! nothing can be fixed during a wind-down, a failing compliance module -//! included. +//! - **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. From 2c3b67da4353f6165d64ed9b8ff290b3f4e13b51 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 07:01:25 -0300 Subject: [PATCH 08/17] test: pin four claims of the design notes --- contracts/async-vault/src/test/compliance.rs | 35 +++++++++++++- contracts/async-vault/src/test/deposit_cap.rs | 37 +++++++++++++++ contracts/async-vault/src/test/wind_down.rs | 47 ++++++++++++++++++- contracts/share-token/src/test.rs | 30 ++++++++++++ 4 files changed, 147 insertions(+), 2 deletions(-) diff --git a/contracts/async-vault/src/test/compliance.rs b/contracts/async-vault/src/test/compliance.rs index ae84f4a0..92c66fc5 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,39 @@ 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_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..5d551cad 100644 --- a/contracts/async-vault/src/test/deposit_cap.rs +++ b/contracts/async-vault/src/test/deposit_cap.rs @@ -178,3 +178,40 @@ 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, + ); +} 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/share-token/src/test.rs b/contracts/share-token/src/test.rs index 52137224..22ef207e 100644 --- a/contracts/share-token/src/test.rs +++ b/contracts/share-token/src/test.rs @@ -707,6 +707,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(); From f4ba9a4f25a67fa96e8159c4cf4b4c2a084a2686 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 07:01:40 -0300 Subject: [PATCH 09/17] docs: always clear a compromised attester's report --- docs/kit-spec.md | 2 +- docs/strata-product-and-architecture.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/kit-spec.md b/docs/kit-spec.md index c4caa665..f6371993 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -219,7 +219,7 @@ 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. If the stored report is wrong, call `clear_latest` (admin; only while the NAV is paused). The next report then has no earlier price to cap its move: only `min_answer` and `max_answer` bound it. +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. diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index 6e39d863..d4a4ce5f 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -617,7 +617,7 @@ public surface given the vault address. | Boundary | Risk | Mitigation | | ---------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Attestation authority | Wrong or compromised reports misprice requests | Multisig reporter; the deviation cap bounds a single report in both directions, and a downward cap of 100% leaves `min_answer` as the only floor; the cooldown bounds frequency; a sustained sequence of biased reports within the cap remains possible, is bounded in speed, and is the monitoring plan's primary alert, with the guardian pause as the reactive control. Residual risk: value transfer between entry and exit cohorts. A compromised attester is replaced in this order: pause the NAV, revoke the attester (instant), grant a replacement (through the timelock after setup, so the NAV stays paused that long), clear the stored report if it is wrong, then lift the pause ([`kit-spec.md`](./kit-spec.md) §3) | +| 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 | From ea0d6611bc9baf3ef64eea7e87d524f2a2a6479c Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 07:02:02 -0300 Subject: [PATCH 10/17] docs: drop stale manager and exit window wording --- contracts/async-vault/src/guards.rs | 3 ++- contracts/async-vault/src/lib.rs | 1 - crates/strata-timelock/src/refusal.rs | 2 +- docs/kit-spec.md | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/contracts/async-vault/src/guards.rs b/contracts/async-vault/src/guards.rs index 2eab6ec7..5d8c6294 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); diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 6b296b35..d423b821 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -138,7 +138,6 @@ impl AsyncVault { oracle: Address, roles: VaultRoles, ) { - // `roles` holds the five authorities and the manager; the attester is not read. guards::roles_distinct(e, &roles); access_control::set_admin(e, &roles.governance); 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/kit-spec.md b/docs/kit-spec.md index f6371993..37e2a13e 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -140,7 +140,7 @@ 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` | @@ -197,7 +197,7 @@ 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, 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 From f07f5f6584fa3d8b573a41b50f53aa984eac59c7 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:05:03 -0300 Subject: [PATCH 11/17] docs: correct role, exit and feed claims --- docs/AUTHORIZATION_MATRIX.md | 8 ++++---- docs/COMPLIANCE_MATRIX.md | 2 +- docs/kit-spec.md | 10 +++++----- environments.toml | 6 +++++- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index a74c0ae6..05721e18 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -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 3a318040..ffa5d39a 100644 --- a/docs/COMPLIANCE_MATRIX.md +++ b/docs/COMPLIANCE_MATRIX.md @@ -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/kit-spec.md b/docs/kit-spec.md index 37e2a13e..022e67f2 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -142,10 +142,10 @@ Deploying the Strata Vault Kit involves five contracts configured via `environme | **`async_vault`** | `roles.guardian` | `Address` | Guardian multisig authority | `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` @@ -182,7 +182,7 @@ 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. @@ -232,7 +232,7 @@ Pause first, so no report lands while the roles change, and lift the pause last: | **`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 | diff --git a/environments.toml b/environments.toml index 9a8d1e4d..44aa96ac 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. From 546fecefe0bf7889aab4cdb6eb896a5d9d67bf6a Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:05:04 -0300 Subject: [PATCH 12/17] docs: narrow the token's compliance claims --- contracts/share-token/src/contract.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/share-token/src/contract.rs b/contracts/share-token/src/contract.rs index f3d8dd15..5b10552a 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 /// From d74f5ce06e13030bd120b9c4dd36bcf7aca65df3 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:05:05 -0300 Subject: [PATCH 13/17] docs: list the spec doc test and its limit --- AGENTS.md | 5 ++++- docs/TESTING.md | 11 +++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 568913a3..44cc096f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -139,7 +139,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/docs/TESTING.md b/docs/TESTING.md index 8a23b76d..b96c9255 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -145,6 +145,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 From 66b60fc6106de310f0f0c09d5efe84dcfc3b6b26 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:05:06 -0300 Subject: [PATCH 14/17] refactor: name two guards for what they check --- contracts/async-vault/src/governance.rs | 4 ++-- contracts/async-vault/src/guards.rs | 11 ++++++----- contracts/async-vault/src/test/guards.rs | 14 ++++++++------ 3 files changed, 16 insertions(+), 13 deletions(-) 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 5d8c6294..577eb4c9 100644 --- a/contracts/async-vault/src/guards.rs +++ b/contracts/async-vault/src/guards.rs @@ -156,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/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)); } } From 3d20c032da7f1c1e4ba2e9cbe333849ab2def9fc Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:12:55 -0300 Subject: [PATCH 15/17] docs(vault): say deposited capital counts money in and out, not value The kit spec and the design doc said a gain or a loss at the custodian never moves deposited capital. It does once exits pay it out: a priced redemption takes off what it pays, gain included. After 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. Two tests pin both directions. --- contracts/async-vault/src/test/deposit_cap.rs | 41 +++++++++++++++++++ docs/kit-spec.md | 2 +- docs/strata-product-and-architecture.md | 8 +++- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/contracts/async-vault/src/test/deposit_cap.rs b/contracts/async-vault/src/test/deposit_cap.rs index 5d551cad..7f836314 100644 --- a/contracts/async-vault/src/test/deposit_cap.rs +++ b/contracts/async-vault/src/test/deposit_cap.rs @@ -215,3 +215,44 @@ fn a_plain_transfer_back_counts_twice_toward_the_cap() { 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/docs/kit-spec.md b/docs/kit-spec.md index 2c6bcdd3..4e174a3f 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -190,7 +190,7 @@ The `async_vault` constructor refuses these four pairs of equal addresses and co ### 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 asset units at cost: a gain or a loss at the custodian never moves it, even once funded back. +- 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. --- diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index d4a4ce5f..d07a8ac3 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -380,8 +380,12 @@ 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`, -in asset units at cost, so a gain or a loss at the custodian never moves it. +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 From 868e4589380008026258808de49885d18c4af27c Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:13:30 -0300 Subject: [PATCH 16/17] docs: answer the review notes on the approved stack - The NotAllowlisted doc and the compliance matrix say that a verifier that cannot answer counts as a failed check, and a test pins it. - The kit spec says this version is deployed fresh, not upgraded in place. - The design doc gives the epoch remainder rule in the pricing section, and says the share token's upgrades, role grants and admin hand-overs wait for the vault's timelock. --- contracts/async-vault/src/error.rs | 4 +++- contracts/async-vault/src/test/compliance.rs | 19 +++++++++++++++++++ docs/COMPLIANCE_MATRIX.md | 2 +- docs/kit-spec.md | 2 +- docs/strata-product-and-architecture.md | 6 +++++- 5 files changed, 29 insertions(+), 4 deletions(-) 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/test/compliance.rs b/contracts/async-vault/src/test/compliance.rs index 92c66fc5..d7bb16e6 100644 --- a/contracts/async-vault/src/test/compliance.rs +++ b/contracts/async-vault/src/test/compliance.rs @@ -146,6 +146,25 @@ fn an_unbound_compliance_swap_lands_and_stops_every_move() { 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/docs/COMPLIANCE_MATRIX.md b/docs/COMPLIANCE_MATRIX.md index ffa5d39a..3131cd90 100644 --- a/docs/COMPLIANCE_MATRIX.md +++ b/docs/COMPLIANCE_MATRIX.md @@ -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. | diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 4e174a3f..1bedb91c 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -79,7 +79,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - **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. diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index d07a8ac3..5866d20e 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -287,7 +287,7 @@ number. | Component | Role | Controls | | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Vault** | Request lifecycle, epoch sealing by its manager role, pricing, split reserve accounting, custodian transfers, upgrade control | One authority per privileged entrypoint; upgrades behind a governance timelock no shorter than the notice, bounded between 7 and 90 days. The share token and the oracle upgrade under the same timelock. Administrative handover follows a strict two-step transfer pattern; renouncing admin is permanently refused (`AdminRequired`) | -| **Share token** | OZ SEP-41 + SEP-57 RWA extensions: freeze, forced transfer, recovery, identity and compliance checks on every transfer, independent transfer pause (set only by the admin; governance by deploy) | Manager role held only by the vault, for mint, burn and escrow; compliance role held by the compliance authority, for freezes, plus legal transfers and recovery co-signed by the token admin; swapping the compliance or identity-verifier contract needs the admin and, after a one-time setup, waits for the vault's timelock | +| **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 +306,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 From c11e1485ff5328a6d07df05cdb52c6ce2504dfd3 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:46:07 -0300 Subject: [PATCH 17/17] docs: say what holders can do if the manager or the attester stops Also say the deposit cap is vault-wide, and list the testnet identity verifier's limits as limits of the stub. --- docs/kit-spec.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 1bedb91c..44ada178 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -159,7 +159,7 @@ The `async_vault` constructor refuses these four pairs of equal addresses and co |---|---|---|---| | **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 (see **Supply and Capital Figures**). `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. | @@ -223,6 +223,16 @@ Pause first, so no report lands while the roles change, and lift the pause last: 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). + --- ### Comprehensive Entrypoint Permission Matrix @@ -498,6 +508,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