diff --git a/README.md b/README.md index a03b7d40..a1e8209a 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,7 @@ vault is allowlisted share_token bound to compliance nav_oracle bound to the vault, setup finished vault setup finished +health {"allowlisted":true,"compliance_bound":true,"feed_valid":false,"freshness_covers_notice":true,"oracle_bound":true,"setup_open":false,"sole_token_manager":true,"token_bound":true} wrote deployed.local.json ``` diff --git a/contracts/async-vault/src/governance.rs b/contracts/async-vault/src/governance.rs index 4329f795..df168453 100644 --- a/contracts/async-vault/src/governance.rs +++ b/contracts/async-vault/src/governance.rs @@ -59,7 +59,8 @@ pub struct VaultPendingChange { /// The share token's role that mints and burns. The vault must hold it alone. const TOKEN_MANAGER_ROLE: Symbol = symbol_short!("manager"); -/// What `finish_setup` needs to see before deposits open. +/// What `finish_setup` needs to see before deposits open. `health()` reports +/// the same checks. pub(crate) struct Wiring { pub token_bound: bool, pub oracle_bound: bool, @@ -149,17 +150,31 @@ pub(crate) fn wiring(e: &Env) -> Wiring { Wiring { token_bound: bound_here(e, &token), oracle_bound: bound_here(e, &oracle), - sole_token_manager: roles.get_role_member_count(&TOKEN_MANAGER_ROLE) == 1 - && roles.get_role_member(&TOKEN_MANAGER_ROLE, &0) == me, - no_shares_outstanding: FungibleTokenClient::new(e, &token).total_supply() == 0, - allowlisted: matches!( - IdentityVerifierClient::new(e, &rwa.identity_verifier()).try_verify_identity(&me), - Ok(Ok(())) + sole_token_manager: matches!( + roles.try_get_role_member_count(&TOKEN_MANAGER_ROLE), + Ok(Ok(1)) + ) && matches!( + roles.try_get_role_member(&TOKEN_MANAGER_ROLE, &0), + Ok(Ok(holder)) if holder == me ), - compliance_bound: matches!( - TokenBinderClient::new(e, &rwa.compliance()).try_linked_tokens(), - Ok(Ok(linked)) if linked.contains(&token) + no_shares_outstanding: matches!( + FungibleTokenClient::new(e, &token).try_total_supply(), + Ok(Ok(0)) ), + allowlisted: match rwa.try_identity_verifier() { + Ok(Ok(verifier)) => matches!( + IdentityVerifierClient::new(e, &verifier).try_verify_identity(&me), + Ok(Ok(())) + ), + _ => false, + }, + compliance_bound: match rwa.try_compliance() { + Ok(Ok(compliance)) => matches!( + TokenBinderClient::new(e, &compliance).try_linked_tokens(), + Ok(Ok(linked)) if linked.contains(&token) + ), + _ => false, + }, } } diff --git a/contracts/async-vault/src/health.rs b/contracts/async-vault/src/health.rs new file mode 100644 index 00000000..c0768428 --- /dev/null +++ b/contracts/async-vault/src/health.rs @@ -0,0 +1,44 @@ +use bindings::{OracleFeedClient, OracleState}; +use soroban_sdk::{contracttype, Env}; + +use crate::governance; +use crate::keys::DataKey; +use crate::state; + +/// What a deployment checks before it trusts the vault, and what monitoring +/// keeps checking after. Every field is true on a healthy vault. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Health { + /// False once `finish_setup` has run. While true, every admin change is + /// instant and deposits are refused. + pub setup_open: bool, + pub token_bound: bool, + pub oracle_bound: bool, + pub sole_token_manager: bool, + pub allowlisted: bool, + pub compliance_bound: bool, + pub feed_valid: bool, + /// A report outlives the notice, so an epoch's cancellation window does + /// not reopen on its own while the notice runs. + pub freshness_covers_notice: bool, +} + +pub(crate) fn health(e: &Env) -> Health { + let wiring = governance::wiring(e); + let feed = OracleFeedClient::new(e, &state::get_addr(e, &DataKey::Oracle)); + let notice = state::notice(e); + Health { + setup_open: timelock::setup::is_open(e), + token_bound: wiring.token_bound, + oracle_bound: wiring.oracle_bound, + sole_token_manager: wiring.sole_token_manager, + allowlisted: wiring.allowlisted, + compliance_bound: wiring.compliance_bound, + feed_valid: matches!(feed.try_state(), Ok(Ok(OracleState::Valid))), + freshness_covers_notice: matches!( + feed.try_freshness_duration(), + Ok(Ok(freshness)) if freshness >= notice + ), + } +} diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index b5dfb9a9..2e87cd24 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -5,6 +5,7 @@ mod epoch; mod error; mod event; mod governance; +mod health; mod keys; mod lock_policy; mod pricing; @@ -38,6 +39,7 @@ pub use event::{ WindDownProposed, WindDownRoundFinalized, }; pub use governance::{VaultChange, VaultPendingChange, ADMIN_ACCEPT_LEDGERS}; +pub use health::Health; pub use pricing::{DirectUnitPricing, PricingScheme}; pub use roles::VaultRoles; pub use settlement::EpochSettlement; @@ -298,6 +300,12 @@ impl AsyncVault { timelock::setup::is_open(e) } + /// The wiring `finish_setup` checks, plus the feed: whether it can price + /// now, and whether its reports outlive the notice. + pub fn health(e: &Env) -> Health { + health::health(e) + } + /// Queues a change behind the timelock and returns its id. #[only_admin] pub fn propose_change(e: &Env, change: VaultChange, caller: Address) -> u32 { diff --git a/contracts/async-vault/src/test/health.rs b/contracts/async-vault/src/test/health.rs new file mode 100644 index 00000000..07bf6a4e --- /dev/null +++ b/contracts/async-vault/src/test/health.rs @@ -0,0 +1,118 @@ +use super::*; +use crate::Health; + +fn all_healthy() -> Health { + Health { + setup_open: false, + token_bound: true, + oracle_bound: true, + sole_token_manager: true, + allowlisted: true, + compliance_bound: true, + feed_valid: true, + freshness_covers_notice: true, + } +} + +#[test] +fn a_finished_vault_with_a_fresh_report_is_healthy() { + let f = setup(); + f.attest(wad(1)); + + assert_eq!(f.vault.health(), all_healthy()); +} + +#[test] +fn an_open_setup_is_reported() { + let f = setup_unfinished(); + f.attest(wad(1)); + + assert_eq!( + f.vault.health(), + Health { + setup_open: true, + ..all_healthy() + } + ); +} + +#[test] +fn each_missing_wiring_step_shows_in_its_own_flag() { + let cases = [ + ( + Miswire::TokenUnbound, + Health { + token_bound: false, + ..all_healthy() + }, + ), + ( + Miswire::OracleUnbound, + Health { + oracle_bound: false, + ..all_healthy() + }, + ), + ( + Miswire::NoManagerRole, + Health { + sole_token_manager: false, + ..all_healthy() + }, + ), + ( + Miswire::SecondManager, + Health { + sole_token_manager: false, + ..all_healthy() + }, + ), + ( + Miswire::NotAllowlisted, + Health { + allowlisted: false, + ..all_healthy() + }, + ), + ( + Miswire::ComplianceUnbound, + Health { + compliance_bound: false, + ..all_healthy() + }, + ), + ]; + for (miswire, expected) in cases { + let f = miswired(miswire); + f.attest(wad(1)); + + assert_eq!( + f.vault.health(), + Health { + setup_open: true, + ..expected + } + ); + } +} + +#[test] +fn a_feed_with_no_report_or_the_nav_paused_is_not_valid() { + let f = setup(); + assert!(!f.vault.health().feed_valid); + + f.attest(wad(1)); + f.oracle.pause_nav(&f.guardian); + assert!(!f.vault.health().feed_valid); +} + +#[test] +fn a_notice_longer_than_a_report_lives_is_reported() { + let f = setup(); + f.attest(wad(1)); + let freshness = f.oracle.config().freshness_duration; + + f.vault.set_notice(&(freshness + 1), &f.admin); + + assert!(!f.vault.health().freshness_covers_notice); +} diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index 1999e52b..e2978d91 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -9,6 +9,7 @@ mod deposit_cap; mod epochs; mod events; mod governance; +mod health; mod lock_policy; mod multi_epoch; mod notice; diff --git a/contracts/async-vault/src/test/setup.rs b/contracts/async-vault/src/test/setup.rs index f2d9a719..2e1c1617 100644 --- a/contracts/async-vault/src/test/setup.rs +++ b/contracts/async-vault/src/test/setup.rs @@ -73,6 +73,69 @@ fn every_wiring_step_is_checked_before_setup_finishes() { } } +/// A token that cannot answer `wiring`'s calls must not trap the vault: every +/// check it needs the token for reads false, and `finish_setup` refuses +/// cleanly instead of aborting the transaction. +#[test] +fn a_token_that_cannot_answer_fails_its_checks_instead_of_trapping() { + let e = Env::default(); + e.mock_all_auths(); + e.ledger().set_timestamp(10_000); + + let issuer = Address::generate(&e); + let asset = e.register_stellar_asset_contract_v2(issuer).address(); + let admin = Address::generate(&e); + let manager = Address::generate(&e); + let treasury = Address::generate(&e); + let guardian = Address::generate(&e); + let compliance_authority = Address::generate(&e); + let attester = Address::generate(&e); + let broken_token = Address::generate(&e); + + let oracle = NavOracleContractClient::new( + &e, + &e.register( + NavOracleContract, + ( + admin.clone(), + attester.clone(), + guardian.clone(), + open_feed(), + ), + ), + ); + + let contract_id = e.register( + AsyncVault, + ( + &asset, + &broken_token, + &oracle.address, + VaultRoles { + governance: admin.clone(), + manager, + treasury, + guardian: guardian.clone(), + compliance: compliance_authority, + attester, + }, + ), + ); + oracle.bind_vault(&contract_id, &admin); + oracle.finish_setup(&admin); + + let vault = AsyncVaultClient::new(&e, &contract_id); + + let health = vault.health(); + assert!(health.oracle_bound); + assert!(!health.token_bound); + assert!(!health.sole_token_manager); + assert!(!health.allowlisted); + assert!(!health.compliance_bound); + + refused(vault.try_finish_setup(&admin), VaultError::SetupIncomplete); +} + #[test] fn setup_cannot_finish_while_a_hand_over_is_pending() { let f = setup_unfinished(); diff --git a/docs/kit-spec.md b/docs/kit-spec.md index b73d276b..35dbc163 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -163,6 +163,23 @@ The `async_vault` constructor strictly verifies pairwise separation across autho | **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. | | **Oracle Feed** | `propose_change(Oracle(address))` โ†’ `apply_change(id)` | Governance | Waits for the timelock. The new feed must name this vault through `vault()` with its own setup finished, at proposal and again at apply, and must answer `state()`. The next fulfilment prices from it, including an epoch sealed before the swap. | +### Health View + +`health()` returns one flag per check. A deployment is ready when `setup_open` is false and every other flag is true. + +| Field | True when | +|---|---| +| `setup_open` | `finish_setup` has not run yet. While true, admin changes are instant and deposits are refused. | +| `token_bound` | The share token names this vault through `vault()` and its own setup is finished. | +| `oracle_bound` | The oracle names this vault through `vault()` and its own setup is finished. | +| `sole_token_manager` | This vault is the share token's only `manager`. | +| `allowlisted` | This vault passes the share token's identity verifier, so it can hold escrowed shares. A delisted holder's deposit converts to an exit by minting the shares to the vault's own address, so that conversion needs this flag true. | +| `compliance_bound` | The share token is in its compliance contract's `linked_tokens`. | +| `feed_valid` | The oracle reports `Valid`: a report exists, it has not expired and the NAV is not paused. | +| `freshness_covers_notice` | The oracle's `freshness_duration` is at least the vault's notice. | + +`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. + --- ## 3. Role & Authority Matrix diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index 04b6bed7..014ff84f 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -590,6 +590,7 @@ public surface given the vault address. | ---------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Attestation authority | Wrong or compromised reports misprice requests | Multisig reporter; the deviation cap bounds a single report in both directions, and a downward cap of 100% leaves `min_answer` as the only floor; the cooldown bounds frequency; a sustained sequence of biased reports within the cap remains possible, is bounded in speed, and is the monitoring plan's primary alert, with the guardian pause as the reactive control. Residual risk: value transfer between entry and exit cohorts | | Governance keys | Malicious upgrade | Timelock on every code change and on the delay itself, bounded by a minimum no shorter than the standing notice and a maximum ceiling (90 days). A pause freezes the timelock clock and pushes the ETA by the duration paused, guaranteeing that investors have an unpaused exit window before any code change applies; applying while paused is refused; nothing is proposed or applied while the vault winds down. The vault's own custodian, role grants, `manager` revocations, admin handovers and lowering of the notice or the wind-down delay wait on that clock once its one-time setup closes, and it takes no deposit before then; revoking its treasury or guardian stays instant. Setup closes only once the token and the oracle run on the vault's clock with their own setup closed, the vault is the token's only minter, no shares exist yet (shares minted earlier would redeem against depositors' money), the vault passes the identity check and the token is bound to its compliance contract. The share token's upgrades, grants, `manager` revocations, admin handovers and compliance or verifier swaps wait on the same clock once its setup closes. So do the oracle's upgrades, grants, admin handovers and every guardrail change except a shorter freshness window; its NAV pause, `clear_latest` and revoking its attester or guardian stay instant. | +| Mis-wired deployment | A vault goes live without every binding wired | `health()` reports each wiring step; `finish_setup` refuses until all hold, and the vault takes no deposit before that | | Treasury keys | Reserve drained | Only free reserve is movable, only to the genesis-configured custodian, verified on-chain; outbound transfers are blocked while anything is uncovered, and escrowed subscriptions never leave the vault | | Guardian keys | Griefing via pause | Guardian can only pause the vault, which stops deposit requests, epoch pricing and upgrade application and freezes the upgrade timelock; it cannot halt redemptions, custodian deployments, or payable claims, nor move funds; governance unpauses and rotates the vault role. It holds no role on the share token: the token's transfer pause is admin-gated (governance by deploy), not a guardian lever. No contract keeps governance and guardian apart | | Share-token pause | Cancellations and secondary transfers halted | Admin-gated (governance by deploy; the token has no guardian role); it refuses SEP-41 transfers and escrowed-share returns only, never pricing, claims, mint, burn or forced transfer; only the token admin lifts it (ยง8.5) | diff --git a/scripts/harness/deploy.ts b/scripts/harness/deploy.ts index a948bc4b..4f7c4c79 100644 --- a/scripts/harness/deploy.ts +++ b/scripts/harness/deploy.ts @@ -238,6 +238,19 @@ async function finishVaultSetup( if ((await vault.setup_open()).result) throw new Error("the vault's setup did not finish") console.log("vault setup finished") + + const health = (await vault.health()).result + console.log("health", JSON.stringify(health)) + const wiring = [ + "token_bound", + "oracle_bound", + "sole_token_manager", + "allowlisted", + "compliance_bound", + ] as const + const broken = wiring.filter((flag) => !health[flag]) + if (health.setup_open || broken.length > 0) + throw new Error(`the vault is not wired: ${broken.join(", ")}`) } async function main(): Promise {