diff --git a/AGENTS.md b/AGENTS.md index 9f446123..5fcd374d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,8 +28,10 @@ What the contracts enforce: allowlist place. Priced claims are never re-priced and never identity-gated; a delisted, non-frozen investor leaves through the exit-only cash path, including a deposit that was priced before the delisting. -3. **Cancellation is atomic and single-step**, open only until the attestation - that prices the request is accepted. There is no instant exit. +3. **Cancellation is atomic and single-step**, open only while the vault cannot + price the request: the attestation that prices it closes cancellation, and + cancellation reopens if that report expires, the NAV is paused or the vault + is paused. There is no instant exit. Five authorities, each a native Stellar multisig: governance, compliance, attestation, treasury, guardian. Testnet only. Not audited. diff --git a/contracts/async-vault/src/error.rs b/contracts/async-vault/src/error.rs index 771fc592..36faee2d 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -123,9 +123,9 @@ pub enum VaultError { UseTimelock = 6065, /// Setup has finished, and it never reopens. SetupClosed = 6066, - /// The token, the oracle, the manager role, the allowlist or the - /// compliance binding is not wired to this vault yet, or the token already - /// has shares outstanding, so setup cannot finish. + /// A wiring check `health()` reports is false (token, oracle and compliance + /// bound, sole token manager, allowlisted, not frozen) or the token has + /// 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. diff --git a/contracts/async-vault/src/governance.rs b/contracts/async-vault/src/governance.rs index 8cf8c8cc..89179d4c 100644 --- a/contracts/async-vault/src/governance.rs +++ b/contracts/async-vault/src/governance.rs @@ -60,7 +60,8 @@ pub struct VaultPendingChange { const TOKEN_MANAGER_ROLE: Symbol = symbol_short!("manager"); /// What `finish_setup` needs to see before deposits open. `health()` reports -/// the same checks. +/// every check but `no_shares_outstanding`: a healthy vault has shares +/// outstanding from its first claim on. pub(crate) struct Wiring { pub token_bound: bool, pub oracle_bound: bool, diff --git a/contracts/async-vault/src/health.rs b/contracts/async-vault/src/health.rs index 5b6ec76e..894f5290 100644 --- a/contracts/async-vault/src/health.rs +++ b/contracts/async-vault/src/health.rs @@ -6,7 +6,8 @@ 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. +/// keeps checking after. On a healthy vault `setup_open` is false and every +/// other field is true. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct Health { diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 9c0980fe..fcdb44e7 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -286,10 +286,10 @@ impl AsyncVault { governance::set_notice(e, secs); } - /// Ends the window in which admin changes are instant, once the token, - /// the oracle, the manager role, the allowlist and the compliance binding - /// are wired to this vault and the token has no shares outstanding. - /// Deposits open from here on, and it never reopens. + /// Ends the window in which admin changes are instant. Needs every wiring + /// check `health()` reports (token, oracle and compliance bound, sole token + /// manager, allowlisted, not frozen), no shares outstanding and no admin + /// hand-over pending. Deposits open from here on, and it never reopens. #[only_admin] pub fn finish_setup(e: &Env, caller: Address) { caller.require_auth(); @@ -300,8 +300,9 @@ 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. + /// Whether setup is open, the wiring `finish_setup` checks except the share + /// supply, and the feed: whether it can price now, and whether its reports + /// outlive the notice. pub fn health(e: &Env) -> Health { health::health(e) } diff --git a/contracts/async-vault/src/redeem.rs b/contracts/async-vault/src/redeem.rs index 72721033..bf4ec6fb 100644 --- a/contracts/async-vault/src/redeem.rs +++ b/contracts/async-vault/src/redeem.rs @@ -208,9 +208,9 @@ fn deliver(e: &Env, controller: &Address, shares: i128) { let vault = e.current_contract_address(); let token = state::get_addr(e, &DataKey::ShareToken); let share = RWATokenClient::new(e, &token); - let rules = RulesClient::new(e, &share.compliance()); if lock_policy::standing(e, controller) != Standing::Listed - || !rules.can_transfer(&vault, controller, &shares, &token) + || !RulesClient::new(e, &share.compliance()) + .can_transfer(&vault, controller, &shares, &token) { panic_with_error!(e, VaultError::HolderNotEligible); } diff --git a/contracts/async-vault/src/test/ttl.rs b/contracts/async-vault/src/test/ttl.rs index 3e508440..06203925 100644 --- a/contracts/async-vault/src/test/ttl.rs +++ b/contracts/async-vault/src/test/ttl.rs @@ -4,7 +4,7 @@ use storage::{INSTANCE_EXTEND_AMOUNT, PERSISTENT_EXTEND_AMOUNT}; use super::*; use crate::keys::DataKey; -const TWO_DAYS: u64 = 2 * 24 * 60 * 60; +const TWO_DAYS: u64 = 2 * DAY_DURATION; fn persistent_ttl(f: &Fixture, key: &DataKey) -> u32 { f.e.as_contract(&f.vault.address, || f.e.storage().persistent().get_ttl(key)) @@ -93,7 +93,7 @@ fn a_pending_request_still_claims_after_most_of_a_month() { f.vault.request_deposit(&user, &400); let epoch = f.close_epoch(); - f.advance(29 * 24 * 60 * 60); + f.advance(29 * DAY_DURATION); f.oracle.attest( &NavReport { nav_per_share: wad(2), diff --git a/contracts/async-vault/src/test/upgrade.rs b/contracts/async-vault/src/test/upgrade.rs index 5a106bd6..dd01ca0c 100644 --- a/contracts/async-vault/src/test/upgrade.rs +++ b/contracts/async-vault/src/test/upgrade.rs @@ -293,16 +293,32 @@ fn only_governance_applies() { assert_eq!(f.vault.pending_changes().len(), 1); } +/// Whether the host refused a call to `function` because the vault has no +/// function of that name. The reason is only in the diagnostic events. +fn has_no_function(e: &Env, function: &str) -> bool { + use soroban_sdk::xdr::{ContractEventBody, ScError, ScErrorCode, ScSymbol, ScVal}; + let missing = ScVal::Error(ScError::Context(ScErrorCode::MissingValue)); + let name = ScVal::Symbol(ScSymbol(function.try_into().unwrap())); + e.host() + .get_diagnostic_events() + .unwrap() + .0 + .iter() + .any(|event| { + let ContractEventBody::V0(body) = &event.event.body; + body.topics.contains(&missing) + && matches!(&body.data, ScVal::Vec(Some(args)) if args.contains(&name)) + }) +} + #[test] fn the_vault_exposes_no_immediate_upgrade() { let f = setup(); - let hash = wasm(&f, 7); - - // If the Upgradeable trait were implemented, this client would work - // against the vault and replace the code with no delay at all. - let client = + let upgrader = stellar_contract_utils::upgradeable::UpgradeableClient::new(&f.e, &f.vault.address); - assert!(client.try_upgrade(&hash, &f.admin).is_err()); + + assert!(upgrader.try_upgrade(&wasm(&f, 7), &f.admin).is_err()); + assert!(has_no_function(&f.e, "upgrade")); } #[test] diff --git a/contracts/async-vault/src/test/wind_down.rs b/contracts/async-vault/src/test/wind_down.rs index 87b5499d..811f8bb8 100644 --- a/contracts/async-vault/src/test/wind_down.rs +++ b/contracts/async-vault/src/test/wind_down.rs @@ -48,7 +48,7 @@ fn a_fresh_vault_is_not_winding_down() { assert_eq!(f.vault.wind_down(), None); } -const WEEK: u64 = 7 * 24 * 60 * 60; +const WEEK: u64 = 7 * DAY_DURATION; #[test] fn governance_proposes_and_anyone_activates_after_the_delay() { @@ -593,7 +593,7 @@ fn a_position_survives_the_ledger_advancing() { f.vault.finalize_wind_down_round(); f.vault.claim_wind_down(&user); - f.advance(29 * 24 * 60 * 60); + f.advance(29 * DAY_DURATION); f.vault.fund(&backer, &800); f.vault.finalize_wind_down_round(); diff --git a/contracts/nav-oracle/src/lib.rs b/contracts/nav-oracle/src/lib.rs index bf10ba75..cbe84820 100644 --- a/contracts/nav-oracle/src/lib.rs +++ b/contracts/nav-oracle/src/lib.rs @@ -39,7 +39,8 @@ pub enum OracleError { NavOutOfBand = 3002, /// Attested before `cooldown_secs` elapsed since the last record. CooldownActive = 3003, - /// Per-share move exceeds `max_deviation_bps`. + /// The move from the last record exceeds `max_up_bps` (a rise) or + /// `max_down_bps` (a fall). DeviationExceeded = 3004, /// `expires_at` not in the future — the record would be born stale. ExpiresInPast = 3005, diff --git a/contracts/nav-oracle/src/test_governance.rs b/contracts/nav-oracle/src/test_governance.rs index 3cdbcb5e..dd265fcd 100644 --- a/contracts/nav-oracle/src/test_governance.rs +++ b/contracts/nav-oracle/src/test_governance.rs @@ -109,6 +109,20 @@ impl Fixture<'_> { call(); assert_eq!(self.instance_ttl(), INSTANCE_EXTEND_AMOUNT); } + + /// Signs one call to the oracle as `who` and nothing else, so the call + /// carries the caller's signature but not the admin's. + fn sign_only_as(&self, who: &Address, fn_name: &str, args: soroban_sdk::Vec) { + self.e.mock_auths(&[MockAuth { + address: who, + invoke: &MockAuthInvoke { + contract: &self.oracle.address, + fn_name, + args, + sub_invokes: &[], + }, + }]); + } } fn config() -> OracleConfig { @@ -465,12 +479,25 @@ fn an_address_that_reports_no_clock_cannot_be_bound() { fn only_the_admin_binds_and_finishes() { let f = setup(); let stranger = Address::generate(&f.e); - f.e.set_auths(&[]); + f.sign_only_as( + &stranger, + "bind_vault", + (f.vault.address.clone(), stranger.clone()).into_val(&f.e), + ); assert!(f .oracle .try_bind_vault(&f.vault.address, &stranger) .is_err()); + assert_eq!(f.oracle.vault(), None); + + f.e.mock_all_auths(); + f.oracle.bind_vault(&f.vault.address, &f.admin); + f.sign_only_as( + &stranger, + "finish_setup", + (stranger.clone(),).into_val(&f.e), + ); assert!(f.oracle.try_finish_setup(&stranger).is_err()); assert!(f.oracle.setup_open()); } @@ -820,9 +847,23 @@ fn only_the_admin_proposes_cancels_and_applies() { let id = f.propose(change.clone()); f.advance(MIN_DELAY); - f.e.set_auths(&[]); + f.sign_only_as( + &stranger, + "propose_change", + (change.clone(), stranger.clone()).into_val(&f.e), + ); assert!(f.oracle.try_propose_change(&change, &stranger).is_err()); + f.sign_only_as( + &stranger, + "cancel_change", + (id, stranger.clone()).into_val(&f.e), + ); assert!(f.oracle.try_cancel_change(&id, &stranger).is_err()); + f.sign_only_as( + &stranger, + "apply_change", + (id, stranger.clone()).into_val(&f.e), + ); assert!(f.oracle.try_apply_change(&id, &stranger).is_err()); assert_eq!(f.oracle.pending_changes().len(), 1); } diff --git a/contracts/share-token/src/test_governance.rs b/contracts/share-token/src/test_governance.rs index 2e5e07e6..f394bba3 100644 --- a/contracts/share-token/src/test_governance.rs +++ b/contracts/share-token/src/test_governance.rs @@ -98,6 +98,20 @@ impl Fixture<'_> { call(); assert_eq!(self.instance_ttl(), INSTANCE_EXTEND_AMOUNT); } + + /// Signs one call to the token as `who` and nothing else, so the call + /// carries the caller's signature but not the admin's. + fn sign_only_as(&self, who: &Address, fn_name: &str, args: soroban_sdk::Vec) { + self.e.mock_auths(&[MockAuth { + address: who, + invoke: &MockAuthInvoke { + contract: &self.token.address, + fn_name, + args, + sub_invokes: &[], + }, + }]); + } } fn setup<'a>() -> Fixture<'a> { @@ -197,9 +211,22 @@ fn an_address_that_reports_no_clock_cannot_be_bound() { fn only_the_admin_binds_and_finishes() { let f = setup(); let stranger = Address::generate(&f.e); - f.e.set_auths(&[]); + f.sign_only_as( + &stranger, + "bind_vault", + (f.vault.address.clone(), stranger.clone()).into_val(&f.e), + ); assert!(f.token.try_bind_vault(&f.vault.address, &stranger).is_err()); + assert_eq!(f.token.vault(), None); + + f.e.mock_all_auths(); + f.token.bind_vault(&f.vault.address, &f.admin); + f.sign_only_as( + &stranger, + "finish_setup", + (stranger.clone(),).into_val(&f.e), + ); assert!(f.token.try_finish_setup(&stranger).is_err()); assert!(f.token.setup_open()); } @@ -481,9 +508,23 @@ fn only_the_admin_proposes_cancels_and_applies() { let id = f.propose(change.clone()); f.advance(MIN_DELAY); - f.e.set_auths(&[]); + f.sign_only_as( + &stranger, + "propose_change", + (change.clone(), stranger.clone()).into_val(&f.e), + ); assert!(f.token.try_propose_change(&change, &stranger).is_err()); + f.sign_only_as( + &stranger, + "cancel_change", + (id, stranger.clone()).into_val(&f.e), + ); assert!(f.token.try_cancel_change(&id, &stranger).is_err()); + f.sign_only_as( + &stranger, + "apply_change", + (id, stranger.clone()).into_val(&f.e), + ); assert!(f.token.try_apply_change(&id, &stranger).is_err()); assert_eq!(f.token.pending_changes().len(), 1); } diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index 9d30973b..e1de3f84 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -65,11 +65,11 @@ The following entrypoints are open to any caller on purpose: | **`AsyncVault`** | `finalize_wind_down_round` | Open (Any caller) | Distributing returned capital pro-rata across the supply snapshot is deterministic. Making finalisation open ensures investors or keepers can trigger payout rounds immediately when funds arrive. | | **`AsyncVault`** | `claim_wind_down` | `holder.require_auth()` | The holder signs their own claim; a keeper cannot claim for them. The payout goes to `holder`. | | **`AsyncVault`** | `request_deposit` | `from.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` | `caller.require_auth()` | Investor entrypoint. Claims shares once epoch fulfilled; gated by SEP-57 receiver check on `mint`. | -| **`AsyncVault`** | `cancel_deposit` | `from.require_auth()` | Investor exit before epoch pricing. Single-step refund. | +| **`AsyncVault`** | `claim_deposit` | `caller.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` | `from.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` | `from.require_auth()` | Investor exit. Always open, even during pause. | | **`AsyncVault`** | `claim_redeem` | `caller.require_auth()` | Investor exit. Pays settlement asset once covered. | -| **`AsyncVault`** | `cancel_redeem` | `from.require_auth()` | Investor exit before epoch pricing. Returns escrowed shares. | +| **`AsyncVault`** | `cancel_redeem` | `from.require_auth()` | Investor exit while the epoch cannot be priced. Outside a wind-down the vault checks the controller itself and returns the escrowed shares with the manager `forced_transfer`; it refuses a delisted controller (`NotAllowlisted`) and a move the compliance rules refuse (`HolderNotEligible`). In an active wind-down the escrow is surrendered into the controller's wind-down entitlement instead (`RedemptionSurrendered`). A frozen controller is held (`Frozen`) either way. | --- @@ -93,6 +93,7 @@ Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs. | `set_deposit_cap` | `#[only_admin]`, `caller.require_auth()` | Not negative. Instant both ways, also after setup | `test::governance::the_deposit_cap_stays_instant_both_ways` | | `finish_setup` | `#[only_admin]`, `caller.require_auth()` | Once. The token and the oracle report this vault with their setup closed, the vault is the token's only `manager`, the token's `total_supply` is zero, the vault passes `verify_identity`, the token's `is_frozen` is false for the vault, and the token is in its compliance contract's `linked_tokens`; a compliance contract that answers `vault()` and `setup_open()` reports this vault with its setup closed (one that does not answer them is an external module and is accepted); no admin hand-over offered during setup is pending | `test::setup::every_wiring_step_is_checked_before_setup_finishes`, `test::setup::a_compliance_contract_from_outside_the_kit_is_accepted`, `test::setup::setup_cannot_finish_while_a_hand_over_is_pending`, `test::setup::setup_never_finishes_twice`, `test::setup::only_governance_finishes_setup` | | `grant_role` / `transfer_admin_role` | Admin | Instant during setup, `UseTimelock` after. A zero `live_until_ledger` cancels instantly | `test::governance::grants_and_manager_revokes_wait_for_the_timelock`, `test::governance::an_admin_hand_over_waits_but_cancelling_one_does_not` | +| `accept_admin_transfer` | The pending admin's auth | A hand-over is pending; an applied `Admin` change stays open for seven days of ledgers | `test::governance::an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts`, `test::governance::the_hand_over_offer_lasts_seven_days_of_ledgers`, `test::governance::an_admin_hand_over_waits_but_cancelling_one_does_not`, `test::setup::setup_cannot_finish_while_a_hand_over_is_pending` | | `revoke_role` | Admin | `treasury`, `guardian`: instant. `manager`: instant during setup, `UseTimelock` after | `test::governance::grants_and_manager_revokes_wait_for_the_timelock` (instant case: `test::governance::revoking_treasury_or_guardian_stays_instant`; caller check: `test::governance::only_the_admin_revokes_a_role`) | | `set_role_admin` | — | Always refused (`UseTimelock`) | `test::governance::no_role_ever_gets_a_role_admin` | | `propose_wind_down` | `#[only_admin]`, `caller.require_auth()` | No proposal standing, not active | `test::wind_down::only_governance_proposes` | @@ -135,6 +136,7 @@ Tests: [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.r | `grant_role` | Admin, `caller.require_auth()` | Instant during setup; `UseTimelock` after | `test_governance::grants_and_manager_revokes_wait_after_setup` | | `revoke_role` | Admin, `caller.require_auth()` | `compliance`: instant. `manager`: instant during setup, `UseTimelock` after | `test_governance::grants_and_manager_revokes_wait_after_setup` (the instant case: `test_governance::revoking_compliance_stays_instant_after_setup`) | | `transfer_admin_role` | Admin | Instant during setup; `UseTimelock` after. A zero `live_until_ledger` cancels at once | `test_governance::an_admin_hand_over_waits_but_cancelling_one_does_not` | +| `accept_admin_transfer` | The pending admin's auth | A hand-over is pending; an applied `Admin` change stays open for seven days of ledgers | `test_governance::an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts`, `test_governance::the_hand_over_offer_lasts_seven_days_of_ledgers`, `test_governance::an_admin_hand_over_waits_but_cancelling_one_does_not`, `test_governance::setup_cannot_finish_while_a_hand_over_is_pending` | | `set_role_admin` | Refused always (`ShareTokenError::UseTimelock`) | None | `test_governance::no_role_ever_gets_a_role_admin`. A role admin could grant its role with no delay. | | `upgrade` | Not exposed | Upgrades go through `propose_change(Upgrade)` | `test::upgrade::with_wasm::the_token_exposes_no_immediate_upgrade` in `contracts/async-vault` (needs `upgrade_wasm`): the built token refuses `upgrade` with a real uploaded hash because it exports no such function | @@ -161,12 +163,13 @@ Tests: [`contracts/nav-oracle/src/test.rs`](../contracts/nav-oracle/src/test.rs) | `grant_role` | Admin, `caller.require_auth()` | Instant during setup; `UseTimelock` after | `test_governance::grants_wait_after_setup_and_land_through_the_timelock` | | `revoke_role` | Admin, `caller.require_auth()` | Instant: taking an attester or a guardian away moves no value | `test_governance::only_the_admin_revokes_a_role` (the instant case: `test_governance::revoking_an_attester_or_a_guardian_stays_instant_after_setup`) | | `transfer_admin_role` | Admin | Instant during setup; `UseTimelock` after. A zero `live_until_ledger` cancels at once | `test_governance::an_admin_hand_over_waits_but_cancelling_one_does_not` | +| `accept_admin_transfer` | The pending admin's auth | A hand-over is pending; an applied `Admin` change stays open for seven days of ledgers | `test_governance::an_applied_admin_change_starts_a_hand_over_the_next_admin_accepts`, `test_governance::the_hand_over_offer_lasts_seven_days_of_ledgers`, `test_governance::an_admin_hand_over_waits_but_cancelling_one_does_not`, `test_governance::setup_cannot_finish_while_a_hand_over_is_pending` | | `set_role_admin` | Refused always (`OracleError::UseTimelock`) | None | `test_governance::no_role_ever_gets_a_role_admin`. A role admin could grant its role with no delay. | | `upgrade` | Not exposed | Upgrades go through `propose_change(Upgrade)` | `test_governance::with_wasm::the_oracle_exposes_no_immediate_upgrade` (needs `upgrade_wasm`): the built oracle refuses `upgrade` with a real uploaded hash because it exports no such function | ### 5.4 IdentityVerifier (`contracts/identity-verifier`) -A testnet-only stub. Its admin is the compliance key and cannot be transferred: the stub exposes no access-control interface, so replacing the compliance key on it means redeploying. +A testnet-only stub. Its admin is the compliance key and cannot be transferred: the stub exposes no access-control interface, so replacing the compliance key on it means redeploying. So the compliance key loses its power at once only on the share token, where its `compliance` role can be revoked instantly. On this stub it can still allowlist and delist addresses until the token's verifier is swapped for a new deployment; after setup that swap waits for the vault's timelock, at least seven days. Tests: [`contracts/identity-verifier/src/test.rs`](../contracts/identity-verifier/src/test.rs). diff --git a/docs/COMPLIANCE_MATRIX.md b/docs/COMPLIANCE_MATRIX.md index cb0b8dba..3038a909 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 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 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. @@ -36,7 +36,7 @@ Every investor account in the vault ecosystem exists in one of three primary com | **`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. | | **`cancel_redeem`** | ✅ Allowed | ❌ **Refused**; ↪ **surrendered** in a wind-down | ⛔ **Held**; a partial freeze does not stop it: the shares return and stay partly frozen | Outside a wind-down the vault refuses a de-listed controller (`NotAllowlisted`) and holds one whose address is frozen (`Frozen`). For anyone else it checks the receiver itself (passes the identity check, not frozen, accepted by the compliance rules' `can_transfer`) and returns the shares with the manager `forced_transfer`; a move the rules refuse gets `HolderNotEligible`. Neither the vault's own allowlist place nor the share-token pause stops a return. A refused controller stays in the queue and exits in cash once the epoch is priced. In an active wind-down no shares come back: the escrow is burned into the controller's wind-down entitlement (`RedemptionSurrendered`), allowlisted or not. A frozen controller's escrow stays until the freeze is lifted. | -| **`claim_redeem`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); the cash stays in `committed` | **Exit-Only Cash Path**: the settlement asset pays out from the liquid reserve and the escrowed shares are burned from the vault. The cash claim checks `is_frozen` on the share token first; it does not check the allowlist. A partial freeze is not checked. A claim that pays nothing returns the escrowed shares instead, under the same checks as `cancel_redeem`: a de-listed holder's is refused with `HolderNotEligible` and the request stays. | +| **`claim_redeem`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); the cash stays in `committed` | **Exit-Only Cash Path**: the settlement asset pays out from the liquid reserve and the escrowed shares are burned from the vault. The cash claim checks `is_frozen` on the share token first; it does not check the allowlist. A partial freeze is not checked. Outside a wind-down, a claim that pays nothing returns the escrowed shares instead, under the same checks as `cancel_redeem`: a de-listed holder's is refused with `HolderNotEligible` and the request stays. In a wind-down such a claim burns the escrowed shares. | | **`claim_wind_down`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); the slice stays in `wind_down_owed` | **Wind-Down Payout**: pays the holder's pro-rata entitlement and burns the free shares it surrenders. The vault checks `is_frozen` and `get_frozen_tokens` on the share token first; a partial freeze surrenders only the free shares — the rest keeps earning every round and is paid once unfrozen. | | **`transfer`** (Secondary) | ✅ Allowed | ❌ **Refused** | ❌ **Refused** | Standard peer-to-peer share transfer via SEP-57. Requires both sender and receiver to be allowlisted and neither to be frozen. | | **`legal_transfer`** (compliance + governance) | ✅ Allowed | ✅ Allowed | ✅ Allowed | Moves shares in the holder's wallet under a legal order; the destination must pass `verify_identity`; never into or out of the vault. It cannot reach value the vault holds for a frozen holder; only an unfreeze releases that. | diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 1b322c01..f1f91aeb 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -45,7 +45,7 @@ The contracts enforce a set of mathematical and state-machine invariants across 1. The vault is paused (`paused == true`). 2. The oracle feed has expired (`is_stale == true`) or the NAV is paused. 3. The caller has been de-listed in the `IdentityVerifier`. - Priced claims are never re-priced and never identity-gated. A frozen investor's covered claim is held until the freeze is lifted (`VaultError::Frozen`); the cash stays in `committed`. + Priced claims are never re-priced, and a cash payout never needs the identity check. A frozen investor's covered claim is held until the freeze is lifted (`VaultError::Frozen`); the cash stays in `committed`. ### Invariant 5: Exit Rights Never Gated or Pausable - **Definition**: Investors cannot be trapped in the vault by a vault pause. @@ -53,7 +53,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - `request_deposit` is refused by `#[when_not_paused]` (`PausableError::EnforcedPause`). - `request_redeem` succeeds and locks shares into escrow. - `claim_redeem` and `claim_deposit` remain open. - - `cancel_redeem` remains open; a delisted controller exits through the cash claim instead, and a frozen one is held (Invariant 6). + - `cancel_redeem` remains open. Outside a wind-down, a delisted controller, or one the compliance rules refuse, exits through the cash claim instead; in a wind-down the escrow is surrendered. A frozen controller is held (Invariant 6). - `cancel_deposit` and `cancel_redeem` stay open for a sealed epoch while the vault is paused, because a paused vault cannot price it (Invariant 6). ### Invariant 6: Single-Step Atomic Cancellation @@ -65,7 +65,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - While an epoch is `Open`, or `Pending` with no readable price or with the vault paused, `cancel_deposit` and `cancel_redeem` return 100% of the escrow. - A readable price refuses cancellation with `PriceAvailable`, even before `priceable_at`; once that report expires, cancellation works again (`cancellation_reopens_when_the_price_that_closed_it_expires`). - Pausing the vault reopens cancellation; unpausing closes it again while the price is still readable (`a_paused_vault_reopens_cancellation_of_an_epoch_it_could_price`). - - Outside a wind-down, `cancel_redeem` refuses a delisted controller (`NotAllowlisted`), holds a frozen one (`Frozen`) and refuses a move the compliance rules refuse (`HolderNotEligible`); the request stays pending and the cash claim remains open. Neither the share token's transfer pause nor the vault's own allowlist place stops a cancellation (`a_token_pause_stops_trading_but_not_a_share_return`, `a_delisted_vault_still_returns_escrowed_shares`). + - Outside a wind-down, `cancel_redeem` refuses a delisted controller (`NotAllowlisted`) and a move the compliance rules refuse (`HolderNotEligible`); the request stays pending and the cash claim remains open. It holds a frozen controller (`Frozen`): the request stays pending, and its cash claim is held too until the freeze is lifted. Neither the share token's transfer pause nor the vault's own allowlist place stops a cancellation (`a_token_pause_stops_trading_but_not_a_share_return`, `a_delisted_vault_still_returns_escrowed_shares`). - In an active wind-down, `cancel_redeem` never returns shares: the escrow joins the holder's wind-down entitlement (`RedemptionSurrendered`), whether the holder is allowlisted or not. A frozen holder's escrow stays until the freeze is lifted. - Once fulfilled, cancellation is refused with `AlreadyPriced`. - **Operating note**: keep `freshness_duration` longer than the notice, so a normal epoch's price does not lapse and reopen the window on its own. @@ -81,7 +81,7 @@ The contracts enforce a set of mathematical and state-machine invariants across - **Testable Property**: For all values of $`a, s \in [1, 10^{30}]`$ and $`\text{NAV} \in [\text{min\_answer}, \text{max\_answer}]`$, a round-trip conversion never creates value. Remainder satisfies: $`(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 has open requests of that kind — 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 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. **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. @@ -171,7 +171,7 @@ The `async_vault` constructor strictly verifies pairwise separation across autho ### Health View -`health()` returns one flag per check. A deployment is ready when `setup_open` is false and every other flag is true. +`health()` returns one flag per check. A deployment is ready when `setup_open` is false and every other flag is true. `health()` leaves out two `finish_setup` checks: that the share token has no shares outstanding, which stops being true once the vault has holders, and that no admin hand-over offered during setup is pending (`AdminTransferPending`). If `finish_setup` refuses with `SetupIncomplete` while `token_bound`, `oracle_bound`, `sole_token_manager`, `allowlisted`, `vault_unfrozen` and `compliance_bound` are all true, shares were minted before setup; the token's `total_supply()` shows them. | Field | True when | |---|---| @@ -180,7 +180,7 @@ The `async_vault` constructor strictly verifies pairwise separation across autho | `oracle_bound` | The oracle names this vault through `vault()` and its own setup is finished. | | `sole_token_manager` | This vault is the share token's only `manager`. | | `allowlisted` | This vault passes the share token's identity verifier. Escrowing and returning shares do not need it. Only a delisted holder's conversion does: it mints the shares into the vault's own escrow. While this flag is false, those conversions wait until the vault is allowlisted again, and nothing is lost. | -| `vault_unfrozen` | The share token has not frozen this vault's address. The vault's own share moves ignore a freeze, so a frozen vault would look stopped while stopping nothing; `finish_setup` refuses it all the same. | +| `vault_unfrozen` | The share token has not frozen this vault's address. The vault's own share moves ignore an address freeze, so freezing the vault stops nothing; `finish_setup` still refuses a frozen vault. | | `compliance_bound` | The share token is in its compliance contract's `linked_tokens`, and a compliance contract that answers `vault()` and `setup_open()` names this vault with its own setup finished. One that does not answer them is an external module and passes; its own governance is a trust assumption. | | `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. | @@ -369,10 +369,12 @@ mainnet. out: 30 days after it was last extended (90 for a role), or the network's minimum lifetime after it was created, whichever is later. Run `STELLAR_ACCOUNT= npm run extend -w scripts/harness` after -every deploy and before a long quiet period. It restores anything archived -and extends every instance and its Wasm by 30 days. It reads the contracts -in `environments.toml [staging.contracts]`; `--env ` picks another -environment, and `--config ` reads a deployment record instead. +every deploy and before a long quiet period. It restores, when archived, and +extends by 30 days three kinds of entry only: each contract instance, the Wasm +it runs, and each pending timelock change. Other entries, such as balances and +roles, are restored by the call that needs them (see **Restoring**). It reads +the contracts in `environments.toml [staging.contracts]`; `--env ` picks +another environment, and `--config ` reads a deployment record instead. Extending needs no authority, so any funded account can pay. **Restoring.** Since Protocol 23 an invocation can restore archived entries diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index ef0e05c5..f1b7f4e4 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -287,7 +287,7 @@ number with a proof reference. | Component | Role | Controls | | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Vault** | Request lifecycle, epoch sealing by its manager role, pricing, split reserve accounting, custodian transfers, upgrade control | One authority per privileged entrypoint; upgrades behind a governance timelock no shorter than the notice, bounded between 7 and 90 days. The share token and the oracle upgrade under the same timelock. Administrative handover follows a strict two-step transfer pattern; renouncing admin is permanently refused (`AdminRequired`) | -| **Share token** | OZ SEP-41 + SEP-57 RWA extensions: freeze, forced transfer, recovery, identity and compliance checks on every transfer, independent transfer pause (admin-gated; governance by deploy) | Manager role held only by the vault, for mint, burn and escrow; compliance role held by the compliance authority, for 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 | | **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 | @@ -368,10 +368,13 @@ and exposes it with the liquidity figures it owns: ```text liquid_reserve = reserve - cancellable_deposit_escrow -free_reserve = max(liquid_reserve - committed, 0) +free_reserve = max(liquid_reserve - committed - wind_down_owed, 0) 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 diff --git a/environments.toml b/environments.toml index be5fbf5d..7b115313 100644 --- a/environments.toml +++ b/environments.toml @@ -34,10 +34,11 @@ constructor_args = "--admin me" client = true constructor_args = '--name "Strata Vault USDC" --symbol bvUSDC --admin me --compliance_authority me --compliance compliance --identity_verifier identity_verifier' -# `--config` takes the OracleConfig struct as JSON. The values mirror the -# contract's own tests: a one-hour freshness window, a one-minute cooldown, a -# 10% deviation cap and a 0.5–100 band at the 18-decimal scale. The two i128 -# fields are strings because they do not survive a JSON number round trip. +# `--config` takes the OracleConfig struct as JSON. The values match the +# harness's testnet oracle: a one-hour freshness window, a one-minute cooldown, +# a 10% cap on a rise, no cap on a fall (10,000 bps lets a loss of any size +# land at once) and a 0.5–100 band at the 18-decimal scale. The two i128 fields +# are strings because they do not survive a JSON number round trip. [development.contracts.nav_oracle] client = true constructor_args = "--admin me --attester me --guardian me --config '{\"freshness_duration\":3600,\"cooldown_secs\":60,\"max_up_bps\":1000,\"max_down_bps\":10000,\"min_answer\":\"500000000000000000\",\"max_answer\":\"100000000000000000000\"}'" diff --git a/scripts/harness/extend.ts b/scripts/harness/extend.ts index 1d8f8216..289d102d 100644 --- a/scripts/harness/extend.ts +++ b/scripts/harness/extend.ts @@ -20,9 +20,9 @@ import { type Entry, } from "./lib/ttl.js" -// Restores whatever has been archived, then extends every contract instance -// and the Wasm it runs, so an idle deployment stays callable. Extending needs -// no authority: any funded account pays. +// Restores, when archived, and extends by 30 days each contract instance, the +// Wasm it runs and each pending timelock change, so an idle deployment stays +// callable. Extending needs no authority: any funded account pays. interface DeploymentRecord { contracts: Record diff --git a/scripts/harness/wind-down.test.ts b/scripts/harness/wind-down.test.ts index 66355822..8bb27fea 100644 --- a/scripts/harness/wind-down.test.ts +++ b/scripts/harness/wind-down.test.ts @@ -155,7 +155,7 @@ describe("a wind-down on a throwaway vault", () => { }) it("reads the economic supply after the claim", async () => { - await call("total_economic_supply", "outsider") + expect(await call("total_economic_supply", "outsider")).toBe("accepted") }) it("every other vault function answers through its binding", async () => { @@ -196,75 +196,137 @@ describe("a wind-down on a throwaway vault", () => { "get_existing_roles", "paused", ] - for (const name of views) await call(name, "outsider") - await call("wind_down_claimable", "outsider", { holder: holder() }) - await call("get_epoch", "outsider", { epoch_id: 1n }) - await call("get_epoch_settlement", "outsider", { epoch_id: 1n }) - await call("get_deposit_request", "outsider", { - epoch_id: 1n, - controller: holder(), - }) - await call("get_redeem_request", "outsider", { - epoch_id: 2n, - controller: holder(), - }) - await call("has_role", "outsider", { account: gov, role: "manager" }) - await call("get_role_member_count", "outsider", { role: "manager" }) - await call("get_role_member", "outsider", { role: "manager", index: 0 }) - await call("get_role_admin", "outsider", { role: "manager" }) + for (const name of views) + expect({ [name]: await call(name, "outsider") }).toEqual({ + [name]: "accepted", + }) + const reads: Array<[string, Record]> = [ + ["wind_down_claimable", { holder: holder() }], + ["get_epoch", { epoch_id: 1n }], + ["get_epoch_settlement", { epoch_id: 1n }], + ["get_deposit_request", { epoch_id: 1n, controller: holder() }], + ["get_redeem_request", { epoch_id: 2n, controller: holder() }], + ["has_role", { account: gov, role: "manager" }], + ["get_role_member_count", { role: "manager" }], + ["get_role_member", { role: "manager", index: 0 }], + ["get_role_admin", { role: "manager" }], + ] + for (const [name, args] of reads) + expect({ [name]: await call(name, "outsider", args) }).toEqual({ + [name]: "accepted", + }) - // Writes the wind-down or the timelock refuses: the call still has to - // encode and reach the contract. - const refusedWrites: Array<[string, Signer, Record]> = [ - ["set_deposit_cap", "governance", { cap: 1n, caller: gov }], - ["set_custodian", "governance", { custodian: someone, caller: gov }], - ["set_wind_down_delay", "governance", { secs: 1n, caller: gov }], - ["cancel_wind_down_proposal", "governance", { caller: gov }], - ["set_notice", "governance", { secs: 1n, caller: gov }], - ["finish_setup", "governance", { caller: gov }], + // Simulated, never sent: each write still has to encode, reach the + // contract and get the answer an active wind-down gives it. + const writes: Array<[string, Signer, Record, string]> = [ + ["set_deposit_cap", "governance", { cap: 1n, caller: gov }, "accepted"], + [ + "set_custodian", + "governance", + { custodian: someone, caller: gov }, + "refused: UseTimelock", + ], + [ + "set_wind_down_delay", + "governance", + { secs: 1n, caller: gov }, + "accepted", + ], + [ + "cancel_wind_down_proposal", + "governance", + { caller: gov }, + "refused: WindDownActive", + ], + ["set_notice", "governance", { secs: 1n, caller: gov }, "accepted"], + ["finish_setup", "governance", { caller: gov }, "refused: SetupClosed"], [ "propose_change", "governance", { change: { tag: "Custodian", values: [someone] }, caller: gov }, + "refused: WindDownActive", + ], + [ + "cancel_change", + "governance", + { id: 1, caller: gov }, + "refused: ChangeNotFound", + ], + [ + "apply_change", + "governance", + { id: 1, caller: gov }, + "refused: ChangeNotFound", ], - ["cancel_change", "governance", { id: 1, caller: gov }], - ["apply_change", "governance", { id: 1, caller: gov }], [ "deploy_to_custodian", "treasury", { caller: c.keys.treasury.publicKey(), assets: 1n }, + "refused: WindDownActive", + ], + ["fund", "holder-1", { from: holder(), assets: 1n }, "accepted"], + [ + "cancel_deposit", + "holder-1", + { from: holder(), epoch_id: 1n }, + "refused: AlreadyPriced", + ], + [ + "cancel_redeem", + "holder-1", + { from: holder(), epoch_id: 2n }, + "accepted", + ], + [ + "claim_redeem", + "holder-1", + { caller: holder(), epoch_id: 2n }, + "refused: EpochNotFulfilled", ], - ["fund", "outsider", { from: someone, assets: 1n }], - ["cancel_deposit", "holder-1", { from: holder(), epoch_id: 1n }], - ["cancel_redeem", "holder-1", { from: holder(), epoch_id: 2n }], - ["claim_redeem", "holder-1", { caller: holder(), epoch_id: 2n }], [ "grant_role", "governance", { account: someone, role: "manager", caller: gov }, + "refused: UseTimelock", ], [ "revoke_role", "governance", { account: someone, role: "manager", caller: gov }, + "refused: UseTimelock", + ], + [ + "renounce_role", + "outsider", + { role: "manager", caller: someone }, + "refused: RoleNotHeld", ], - ["renounce_role", "outsider", { role: "manager", caller: someone }], [ "transfer_admin_role", "governance", { new_admin: someone, live_until_ledger: 1 }, + "refused: UseTimelock", ], - ["accept_admin_transfer", "outsider", {}], + ["accept_admin_transfer", "outsider", {}, "refused: NoPendingTransfer"], [ "set_role_admin", "governance", { role: "manager", admin_role: "guardian" }, + "refused: UseTimelock", + ], + ["renounce_admin", "governance", {}, "refused: AdminRequired"], + [ + "pause", + "guardian", + { caller: c.keys.guardian.publicKey() }, + "accepted", ], - ["renounce_admin", "governance", {}], - ["pause", "guardian", { caller: c.keys.guardian.publicKey() }], - ["unpause", "governance", { caller: gov }], + ["unpause", "governance", { caller: gov }, "refused: ExpectedPause"], ] - for (const [name, as, args] of refusedWrites) await call(name, as, args) + for (const [name, as, args, expected] of writes) + expect({ [name]: await call(name, as, args) }).toEqual({ + [name]: expected, + }) const spec = vault(c, c.keys.outsider).spec const every = spec