diff --git a/contracts/async-vault/src/error.rs b/contracts/async-vault/src/error.rs index a4cf8c44..771fc592 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -142,6 +142,10 @@ pub enum VaultError { /// The proposed feed does not name this vault as its own, or its setup is /// still open. Its admin could then change its limits with no delay. FeedNotBound = 6072, + /// The controller may not hold shares (delisted, frozen or refused by the + /// compliance rules), so the escrowed shares stay with the vault and the + /// request stays open. + HolderNotEligible = 6073, /// An admin hand-over offered during setup is still open. Setup finishes /// once it is accepted, cancelled or expired, so no nominee from setup can /// accept after changes start to wait. diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 2e87cd24..9c0980fe 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -391,8 +391,9 @@ impl AsyncVault { } /// Cancels an unpriced redemption. Outside a wind-down the shares come - /// back, so a delisted or frozen controller is refused; in one the escrow - /// joins the controller's entitlement, and a frozen controller is held. + /// back, even while the token is paused, unless the controller is delisted, + /// frozen or refused by the compliance rules; in one the escrow joins the + /// controller's entitlement, and a frozen controller is held. pub fn cancel_redeem(e: &Env, from: Address, epoch_id: u64) -> i128 { redeem::cancel(e, &from, epoch_id) } diff --git a/contracts/async-vault/src/redeem.rs b/contracts/async-vault/src/redeem.rs index c38490b1..72721033 100644 --- a/contracts/async-vault/src/redeem.rs +++ b/contracts/async-vault/src/redeem.rs @@ -1,11 +1,14 @@ use soroban_sdk::{panic_with_error, token::TokenClient, Address, Env}; -use stellar_tokens::{fungible::FungibleTokenClient, rwa::RWATokenClient}; +use stellar_tokens::{ + fungible::FungibleTokenClient, + rwa::{compliance::ComplianceClient as RulesClient, RWATokenClient}, +}; use crate::epoch; use crate::error::VaultError; use crate::event::{RedeemCancelled, RedeemClaimed, RedeemRequested, RedemptionSurrendered}; use crate::keys::DataKey; -use crate::lock_policy::{self, Decision, Exit}; +use crate::lock_policy::{self, Decision, Exit, Standing}; use crate::pricing::{Pricing, PricingScheme}; use crate::settlement; use crate::state::{self, EpochStatus, RedeemRequest}; @@ -182,9 +185,7 @@ pub(crate) fn queue_escrowed(e: &Env, controller: &Address, shares: i128) -> u64 epoch_id } -/// Moves escrowed shares back to the controller and clears the request. Both -/// callers ask the lock policy first; a delisted controller still meets the -/// token's own checked transfer here. +/// Moves escrowed shares back to the controller and clears the request. fn return_shares(e: &Env, controller: &Address, epoch_id: u64, shares: i128) { let mut epoch = state::get_epoch(e, epoch_id) .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); @@ -192,9 +193,7 @@ fn return_shares(e: &Env, controller: &Address, epoch_id: u64, shares: i128) { state::set_epoch(e, epoch_id, &epoch); state::remove_redeem_request(e, epoch_id, controller); - let vault = e.current_contract_address(); - FungibleTokenClient::new(e, &state::get_addr(e, &DataKey::ShareToken)) - .transfer(&vault, controller, &shares); + deliver(e, controller, shares); RedeemCancelled { controller: controller.clone(), @@ -204,6 +203,20 @@ fn return_shares(e: &Env, controller: &Address, epoch_id: u64, shares: i128) { .publish(e); } +/// Checks the receiver, then moves as manager, so the vault's listing and token pause never apply. +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) + { + panic_with_error!(e, VaultError::HolderNotEligible); + } + share.forced_transfer(&vault, controller, &shares, &vault); +} + pub(crate) fn cancel(e: &Env, from: &Address, epoch_id: u64) -> i128 { from.require_auth(); diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index 6f3c899a..b95c7a16 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -19,6 +19,7 @@ mod pricing; mod redeem; mod settlement; mod setup; +mod share_returns; mod supply; mod timing; mod treasury; @@ -370,9 +371,7 @@ fn build<'a>( share.mint(&holder, &100, &admin); share.revoke_role(&admin, &symbol_short!("manager"), &admin); } - // The vault holds escrowed shares, so returning them on cancellation goes - // through the token's checked transfer. That verifies both sides, so the - // vault has to be a recognised holder itself. + // Converting a delisted holder's deposit mints into the vault's own escrow. if miswire != Miswire::NotAllowlisted { identity.allow(&contract_id, &true, &compliance_authority); } diff --git a/contracts/async-vault/src/test/settlement.rs b/contracts/async-vault/src/test/settlement.rs index 71e44408..aded2f31 100644 --- a/contracts/async-vault/src/test/settlement.rs +++ b/contracts/async-vault/src/test/settlement.rs @@ -166,9 +166,9 @@ fn a_request_that_cannot_settle_keeps_its_epoch_open() { f.fulfill_epoch(wad(1) / 2); f.identity.allow(&a, &false, &f.compliance); - assert_eq!( - f.vault.try_claim_redeem(&a, &epoch).err(), - Some(Ok(soroban_sdk::Error::from_contract_error(304))) + refused( + f.vault.try_claim_redeem(&a, &epoch), + VaultError::HolderNotEligible, ); assert_eq!(f.vault.claim_redeem(&b, &epoch), 1); diff --git a/contracts/async-vault/src/test/share_returns.rs b/contracts/async-vault/src/test/share_returns.rs new file mode 100644 index 00000000..92cadfa5 --- /dev/null +++ b/contracts/async-vault/src/test/share_returns.rs @@ -0,0 +1,138 @@ +use soroban_sdk::{contract, contractimpl}; +use stellar_contract_utils::pausable::PausableError; +use stellar_tokens::rwa::compliance::ComplianceHook; + +use super::*; + +#[contract] +struct RefusesEveryTransfer; + +#[contractimpl] +impl RefusesEveryTransfer { + pub fn can_transfer( + _e: Env, + _from: Address, + _to: Address, + _amount: i128, + _token: Address, + ) -> bool { + false + } +} + +#[test] +fn a_delisted_vault_still_returns_escrowed_shares() { + let f = setup(); + let holder = f.holder(500); + let epoch = f.vault.request_redeem(&holder, &200); + + f.identity.allow(&f.vault.address, &false, &f.compliance); + assert!(!f.vault.health().allowlisted); + + assert_eq!(f.vault.cancel_redeem(&holder, &epoch), 200); + assert_eq!(f.shares(&holder), 500); +} + +#[test] +fn a_delisted_controller_cannot_take_shares_back() { + let f = setup(); + let holder = f.holder(500); + let epoch = f.vault.request_redeem(&holder, &200); + f.identity.allow(&holder, &false, &f.compliance); + + refused( + f.vault.try_cancel_redeem(&holder, &epoch), + VaultError::NotAllowlisted, + ); + assert_eq!(f.shares(&holder), 300); + assert!(f.vault.get_redeem_request(&epoch, &holder).is_some()); +} + +#[test] +fn a_frozen_controller_cannot_take_shares_back() { + let f = setup(); + let holder = f.holder(500); + let epoch = f.vault.request_redeem(&holder, &200); + f.share.set_address_frozen(&holder, &true, &f.compliance); + + refused( + f.vault.try_cancel_redeem(&holder, &epoch), + VaultError::Frozen, + ); + assert_eq!(f.shares(&holder), 300); +} + +#[test] +fn a_return_the_compliance_rules_refuse_stays_escrowed() { + let f = setup(); + let holder = f.holder(500); + let epoch = f.vault.request_redeem(&holder, &200); + let module = f.e.register(RefusesEveryTransfer, ()); + f.add_compliance_module(ComplianceHook::CanTransfer, &module); + + refused( + f.vault.try_cancel_redeem(&holder, &epoch), + VaultError::HolderNotEligible, + ); + assert_eq!(f.shares(&f.vault.address), 200); + assert!(f.vault.get_redeem_request(&epoch, &holder).is_some()); +} + +#[test] +fn a_token_pause_stops_trading_but_not_a_share_return() { + let f = setup(); + let holder = f.holder(500); + let other = f.holder(10); + let epoch = f.vault.request_redeem(&holder, &200); + f.share.pause(&f.admin); + + assert_eq!(f.vault.cancel_redeem(&holder, &epoch), 200); + assert_eq!(f.shares(&holder), 500); + assert_eq!( + f.share.try_transfer(&holder, &other, &1), + Err(Ok(PausableError::EnforcedPause.into())) + ); + assert_eq!(f.shares(&holder), 500); +} + +#[test] +fn a_token_pause_does_not_stop_a_worthless_exit_coming_back() { + let f = setup(); + let holder = f.holder(10); + let epoch = f.vault.request_redeem(&holder, &1); + // Priced at half a unit per share, one share pays nothing. + f.fulfill_epoch(WAD_SCALE / 2); + f.share.pause(&f.admin); + + assert_eq!(f.vault.claim_redeem(&holder, &epoch), 0); + assert_eq!(f.shares(&holder), 10); +} + +#[test] +fn a_partial_freeze_does_not_stop_a_return() { + let f = setup(); + let holder = f.holder(500); + let epoch = f.vault.request_redeem(&holder, &200); + f.share.freeze_partial_tokens(&holder, &100, &f.compliance); + + assert_eq!(f.vault.cancel_redeem(&holder, &epoch), 200); + assert_eq!(f.shares(&holder), 500); + assert_eq!(f.share.get_frozen_tokens(&holder), 100); +} + +#[test] +fn a_delisted_controller_with_a_worthless_exit_keeps_the_request() { + let f = setup(); + let holder = f.holder(10); + let epoch = f.vault.request_redeem(&holder, &1); + // Priced at half a unit per share, one share pays nothing. + f.fulfill_epoch(WAD_SCALE / 2); + f.identity.allow(&holder, &false, &f.compliance); + + refused( + f.vault.try_claim_redeem(&holder, &epoch), + VaultError::HolderNotEligible, + ); + assert_eq!(f.vault.pending_burn_shares(), 1); + assert!(f.vault.get_redeem_request(&epoch, &holder).is_some()); +} diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index 2bbbc499..9d30973b 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -26,9 +26,9 @@ The symbol `"manager"` names two completely distinct roles living on separate co | Contract | Role Symbol | Held By | Scope & Privileges | |---|---|---|---| | **`AsyncVault`** | `symbol_short!("manager")` | Operator Multisig / Automation Bot | Strictly calls `close_epoch` to freeze orders for pricing. Holds **no** token minting or burning power. | -| **`ShareToken`** | `symbol_short!("manager")` | `AsyncVault` Contract Instance | Authorized to call `mint` (claim deposits), `burn` (claim redemptions) and `forced_transfer` (escrow redemptions). Holds no freeze or recovery power. | +| **`ShareToken`** | `symbol_short!("manager")` | `AsyncVault` Contract Instance | Authorized to call `mint` (claim deposits), `burn` (claim redemptions) and `forced_transfer` (escrow redemptions and return escrowed shares). Holds no freeze or recovery power. | -The share token has a third role, `"compliance"`, held by the compliance authority: freezes and partial freezes, plus `legal_transfer` and `recover_balance` with governance's co-signature. Only `manager` reaches `forced_transfer`, which the vault uses to escrow redemptions. +The share token has a third role, `"compliance"`, held by the compliance authority: freezes and partial freezes, plus `legal_transfer` and `recover_balance` with governance's co-signature. Only `manager` reaches `forced_transfer`, which the vault uses to escrow redemptions and to return escrowed shares after its own eligibility check. ### Deployment Script Safety In `scripts/harness/deploy.ts` and automated deployment scripts, the separation is enforced structurally: @@ -107,7 +107,7 @@ Tests: [`controls.rs`](../contracts/async-vault/src/test/controls.rs), [`epochs. ### 5.2 ShareToken (`contracts/share-token`) -The token's `admin` is governance; the guardian holds no role on the token. Until `finish_setup`, admin changes are instant, so the deploy can grant the vault the `manager` role. Setup cannot finish while an admin hand-over offered during setup is pending. After setup, the following wait for the vault's timelock through `propose_change`/`apply_change`: grants, revoking `manager`, admin hand-over, `set_compliance`, `set_identity_verifier` and upgrades. Revoking `compliance` and `pause`/`unpause` stay instant. The `manager` role is held by the vault and the `compliance` role by the compliance authority. +The token's `admin` is governance; the guardian holds no role on the token. Until `finish_setup`, admin changes are instant, so the deploy can grant the vault the `manager` role. Setup cannot finish while an admin hand-over offered during setup is pending. After setup, the following wait for the vault's timelock through `propose_change`/`apply_change`: grants, revoking `manager`, admin hand-over, `set_compliance`, `set_identity_verifier` and upgrades. Revoking `compliance` and `pause`/`unpause` stay instant. The `manager` role is held by the vault and the `compliance` role by the compliance authority. The token's `pause` refuses SEP-41 transfers between holders only. The vault's own escrow moves (`forced_transfer`), mint and burn ignore it, so a token pause stops neither entry nor exit. Tests: [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs) and [`contracts/share-token/src/test_governance.rs`](../contracts/share-token/src/test_governance.rs). diff --git a/docs/COMPLIANCE_MATRIX.md b/docs/COMPLIANCE_MATRIX.md index e408cb02..cb0b8dba 100644 --- a/docs/COMPLIANCE_MATRIX.md +++ b/docs/COMPLIANCE_MATRIX.md @@ -35,8 +35,8 @@ Every investor account in the vault ecosystem exists in one of three primary com | **`cancel_deposit`** | ✅ Allowed | ✅ **Allowed** | ❌ **Refused** (`Frozen`); the escrow stays | **Escape Hatch**: Calls `refund_deposit`. Escrowed settlement asset (USDC) is refunded directly to `controller`. 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. | -| **`cancel_redeem`** | ✅ Allowed | ❌ **Refused**; ↪ **surrendered** in a wind-down | ⛔ **Held** | Outside a wind-down the vault refuses a de-listed controller (`NotAllowlisted`) and holds one whose address is frozen (`Frozen`); anyone else gets the shares back through the token's checked `transfer`. That transfer is refused with `EnforcedPause` while the share-token transfer pause is active; the request stays pending until the pause is lifted or the epoch prices. In an active wind-down no shares come back: the escrow is burned into the controller's wind-down entitlement (`RedemptionSurrendered`), allowlisted or not, so no token pause can hold it. 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. | +| **`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_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. | @@ -77,10 +77,9 @@ An address that was never allowlisted cannot reach this state: `request_deposit` ### 3.4 Redemption Cancellation Refusal When an investor requests redemption, their shares are transferred to the vault. If the investor becomes de-listed or frozen before pricing: -- Calling `cancel_redeem` would transfer shares from the vault back to the investor's account (`return_shares`). -- The vault asks the share token first and refuses a receiver off the allowlist (`NotAllowlisted`) or holds a frozen one (`Frozen`), so returning the shares is refused. -- The investor is not allowed to re-enter share possession. They remain in the queue and exit into cash upon epoch fulfillment. -- In an active wind-down nothing is priced again, so a de-listed investor's cancel burns the escrow into their wind-down entitlement instead. A frozen investor's escrow stays until the freeze is lifted. +- The vault checks the controller itself before it returns escrowed shares. A de-listed controller is refused (`NotAllowlisted`), and so is a move the compliance rules refuse (`HolderNotEligible`); either way the investor stays in the queue and exits in cash once the epoch is priced. A frozen controller is held (`Frozen`) until the freeze is lifted. +- The vault moves the shares as the token's `manager` (`forced_transfer`), so removing the vault's own address from the allowlist does not stop returns. A share-token pause does not stop them either: it stops trading between holders, not the vault's escrow. A partial freeze does not stop them: the shares return and stay partly frozen. +- During a wind-down, every cancel burns the escrowed shares into the controller's wind-down entitlement (`RedemptionSurrendered`), listed or not, and they collect pro rata with `claim_wind_down`. A frozen controller's escrow stays until the freeze is lifted. --- @@ -98,6 +97,8 @@ The share token is OpenZeppelin's RWA token. The allowlist and freeze checks sit 0.7.2 has five compliance hooks: `can_transfer` and `can_create` are asked before the state change, and `transferred`, `created` and `destroyed` are told after it. There is no `can_destroy`. +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. --- @@ -116,6 +117,7 @@ The behavior specified in this matrix is verified by on-chain automated test sui * `a_delisted_holder_can_still_claim_their_exit`: Verifies de-listed holders always receive cash payout. * `a_delisted_investor_can_cancel_deposit_and_receive_asset`: Verifies de-listed depositors can cancel and receive cash back. * `a_delisted_holder_cannot_cancel_redemption_back_to_shares`: Verifies de-listed holders cannot abort redemption back into shares. +* `share_returns::*` ([`test/share_returns.rs`](../contracts/async-vault/src/test/share_returns.rs)): Verifies a cancel returns the shares while the vault's own address is delisted or the token is paused, keeps a partial freeze on the returned shares, and refuses a move the compliance rules refuse or a delisted holder's zero-asset claim with `HolderNotEligible`. * `a_frozen_holder_is_paid_only_after_the_freeze_lifts`: Verifies a frozen holder's cancel and claim are both held, and paid only once the freeze lifts. * `a_frozen_or_delisted_holder_cannot_transfer_shares`: Verifies secondary market transfers are strictly refused for de-listed and frozen accounts. * `a_frozen_investor_cannot_request_a_deposit`: Verifies a frozen depositor is refused at request time. diff --git a/docs/TESTING.md b/docs/TESTING.md index 310827f4..d1a67816 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -126,6 +126,7 @@ fixture wires them as the deploy does and finishes the vault's setup; | Deposit asset (Stellar Asset Contract) moves in and out | [`contracts/async-vault/src/test/deposit.rs`](../contracts/async-vault/src/test/deposit.rs), [`contracts/async-vault/src/test/treasury.rs`](../contracts/async-vault/src/test/treasury.rs) | `request_deposit_updates_epoch_and_user_state`, `capital_makes_the_round_trip_to_the_custodian` | | The vault mints and burns shares through its role on the share token | [`contracts/async-vault/src/test/constructor.rs`](../contracts/async-vault/src/test/constructor.rs), [`contracts/async-vault/src/test/redeem.rs`](../contracts/async-vault/src/test/redeem.rs) | `the_vault_holds_the_manager_role_on_the_share_token`, `claim_redeem_pays_assets_and_burns_the_shares` | | The vault checks the identity verifier at request; the share token checks it on mint and transfer | [`contracts/async-vault/src/test/compliance.rs`](../contracts/async-vault/src/test/compliance.rs), [`contracts/share-token/src/test.rs`](../contracts/share-token/src/test.rs) | `an_investor_who_was_never_allowlisted_is_refused_before_any_cash_moves`, `a_frozen_or_delisted_holder_cannot_transfer_shares`, `manager_mints_to_allowlisted_account` | +| The vault returns escrowed shares as the token's `manager` after checking the receiver itself, including the compliance contract's `can_transfer`; neither its own allowlist place nor the token pause stops a return | [`contracts/async-vault/src/test/share_returns.rs`](../contracts/async-vault/src/test/share_returns.rs) | `a_delisted_vault_still_returns_escrowed_shares`, `a_token_pause_stops_trading_but_not_a_share_return`, `a_return_the_compliance_rules_refuse_stays_escrowed` | | The vault reads the oracle and refuses a stale or paused feed | [`contracts/async-vault/src/test/oracle_pricing.rs`](../contracts/async-vault/src/test/oracle_pricing.rs) | `the_epoch_is_priced_at_the_attestation`, `fulfill_is_rejected_once_the_feed_goes_stale`, `a_paused_feed_is_refused_by_the_vault_not_the_oracle` | | The vault finishes setup only once the token and the oracle report it with their own setup closed, it is the token's only `manager`, the token has no shares outstanding, it passes the identity check, its address is not frozen and the token is bound to compliance, which runs on this vault's clock with its setup closed unless it is an external module that does not answer `VaultBinding`; before that it takes no deposit | [`contracts/async-vault/src/test/setup.rs`](../contracts/async-vault/src/test/setup.rs) | `every_wiring_step_is_checked_before_setup_finishes`, `a_compliance_contract_from_outside_the_kit_is_accepted`, `a_deposit_before_setup_finishes_is_refused`, `setup_cannot_finish_while_a_hand_over_is_pending` | | Upgrading replaces the vault's code and migrates once | [`contracts/async-vault/src/test/upgrade.rs`](../contracts/async-vault/src/test/upgrade.rs) (needs `upgrade_wasm`) | `applying_a_wasm_change_replaces_the_code`, `a_migration_refuses_a_second_run` | diff --git a/docs/kit-spec.md b/docs/kit-spec.md index ff02d393..5f2820d2 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -50,7 +50,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; outside a wind-down only the share token's own transfer pause can refuse it (Invariant 6). + - `cancel_redeem` remains open; a delisted controller exits through the cash claim instead, and a frozen one 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 @@ -62,7 +62,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` is refused with `PausableError::EnforcedPause` while the share token's transfer pause is active; the request stays pending and the cash claim remains open. + - 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`). - 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. @@ -76,7 +76,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 the share token refuses (`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 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. **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. @@ -174,8 +174,8 @@ The `async_vault` constructor strictly verifies pairwise separation across autho | `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. | -| `vault_unfrozen` | The share token has not frozen this vault's address. A frozen vault could not hand escrowed shares back when a holder cancels a redemption. | +| `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. | | `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. | @@ -292,7 +292,7 @@ A clean deployment executes in three sequenced phases: 2. **Phase 2: Post-Deployment Role Wiring** - Call `ShareToken::grant_role("manager", vault_address)` to authorize lazy share minting and burning. - Call `ShareToken::bind_vault(vault_address)`, then `ShareToken::finish_setup()`. The token's admin changes are instant until then and wait for the vault's timelock afterwards. - - Call `IdentityVerifier::allow(vault_address, true)`, signed by the compliance key, so the vault can return escrowed shares on cancelled redemptions. + - Call `IdentityVerifier::allow(vault_address, true)`, signed by the compliance key, so a delisted holder's priced deposit can be minted into the vault's escrow; `finish_setup` requires it. - Call `Compliance::bind_token(share_token_address)` to link SEP-57 rule verification, then `Compliance::bind_vault(vault_address)` and `Compliance::finish_setup()`. Its module and binding changes are instant until then and wait for the vault's timelock afterwards. - Call `NavOracle::bind_vault(vault_address)`, then `NavOracle::finish_setup()`. The oracle's admin changes are instant until then and wait for the vault's timelock afterwards. - On the vault, call `set_custodian(custodian)`, `set_notice(secs)` and, if initial capital intake is bounded, `set_deposit_cap(cap)`. All three are instant during setup, so deposits never open without the cap. diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index 7a8aaaab..ef0e05c5 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -396,12 +396,12 @@ and stale are independent: freshness lapses on its own, the pause is an administrative decision. **The share token's pause is separate.** The token has its own transfer pause, -gated on the token's admin (governance by deploy); the token has no guardian -role. While active it refuses SEP-41 transfers and the vault's escrowed-share -return (`cancel_redeem`); pricing, share and cash claims, mint, burn and forced -transfer are unaffected. It is a transfer-restriction control, not an emergency -stop for the vault, and only the token admin lifts it: pending cancellations -resume when it does. +controlled by the token's admin (governance) and never by the guardian. While +active it refuses SEP-41 transfers between holders. The vault's own escrow +moves (escrowing a redemption, returning escrowed shares on `cancel_redeem`), +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`). @@ -607,8 +607,9 @@ public surface given the vault address. | 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) | +| Share-token pause | Secondary transfers halted | Controlled by governance (the token admin), never the guardian; it refuses SEP-41 transfers between holders only, never the vault's escrow moves, pricing, claims, mint, burn or forced transfer; only governance lifts it (§8.5) | | Compliance keys | Wrongful delisting, freeze or transfer | Delisted investors keep the exit-only cash path; freezes require the share token's compliance role and are auditable per operation; moving a holder's shares also needs governance's signature in the same call, lands only on an allowlisted account and never moves shares out of or into the vault's address, which cannot be frozen once the token knows its vault | +| Vault's own listing | Compliance delists the vault's address | Share returns do not depend on it: the vault checks the holder itself and moves the shares as the token's manager. A delisted holder's conversion, which mints into the vault's escrow, waits until the vault is allowlisted again; nothing is lost, and `health().allowlisted` turns false | | Compliance module | Faulty module blocks transfers | Fail-closed semantics. Governance, the compliance contract's admin, adds and removes modules: instantly during its one-time setup, and after it only through the vault's timelock, so a change cannot land while the vault is paused and never lands during a wind-down. Removing a faulty module waits the same way, and during a wind-down it cannot be removed at all (§8.7) | | Deposit asset issuer | Freeze or clawback of the vault's reserve | Not mitigated by the kit; declared risk of the chosen asset, verified and reported at genesis (auth flags) | | Custodian / real world | Underlying loss or delay | Reflected through attested NAV; the kit constrains what reaches the chain, it does not verify the world | diff --git a/scripts/harness/deploy.ts b/scripts/harness/deploy.ts index 820611bb..9f161ea9 100644 --- a/scripts/harness/deploy.ts +++ b/scripts/harness/deploy.ts @@ -131,9 +131,9 @@ async function deployContracts( } } -// The vault mints and burns, so it needs the token's manager role; it returns -// escrowed shares through the checked transfer, so it must itself be -// allowed; and compliance only runs its hooks for a bound token, so the +// The vault mints and burns, so it needs the token's manager role; it mints a +// delisted holder's converted deposit into its own escrow, so it must itself +// be allowed; and compliance only runs its hooks for a bound token, so the // share token must be bound to it. async function grantAndVerify( net: Network,