diff --git a/contracts/async-vault/src/deposit.rs b/contracts/async-vault/src/deposit.rs index 7104cdb..d642141 100644 --- a/contracts/async-vault/src/deposit.rs +++ b/contracts/async-vault/src/deposit.rs @@ -226,6 +226,14 @@ pub(crate) fn cancel(e: &Env, from: &Address, epoch_id: u64) -> i128 { let request = state::get_deposit_request(e, epoch_id, from) .unwrap_or_else(|| panic_with_error!(e, VaultError::RequestNotFound)); + let standing = lock_policy::standing(e, from); + match lock_policy::decide(Exit::CancelDeposit, standing, wind_down::is_active(e)) { + Decision::Proceed => {} + Decision::Refuse | Decision::Hold | Decision::ConvertToExit | Decision::Surrender => { + panic_with_error!(e, lock_policy::refused_with(standing)) + } + } + state::set_cancellable_escrow( e, state::reduce(e, state::cancellable_escrow(e), request.amount), diff --git a/contracts/async-vault/src/error.rs b/contracts/async-vault/src/error.rs index efd6413..bedafb7 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -121,7 +121,8 @@ pub enum VaultError { /// A running total would go below zero, which means the vault's books /// disagree with themselves. Fails loudly instead of clamping to zero. AccountingMismatch = 6070, - /// The holder is frozen: a deposit request is refused, and a share claim or - /// a redemption cancel waits until the freeze is lifted. + /// The holder is frozen. What the vault owes them is held, not paid, + /// returned or moved, until compliance lifts the freeze. A partial freeze + /// refuses only what would touch frozen shares. Frozen = 6071, } diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 657677d..bbccd4a 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -249,8 +249,9 @@ impl AsyncVault { wind_down::acc(e) } - /// Surrenders whatever the holder holds and pays their share of every round - /// finalised since they last claimed. + /// Surrenders the free shares the holder holds and pays their share of + /// every round finalised since they last claimed. A frozen holder is + /// refused; a partial freeze surrenders only the free shares. pub fn claim_wind_down(e: &Env, holder: Address) -> i128 { wind_down::claim(e, &holder) } diff --git a/contracts/async-vault/src/lock_policy.rs b/contracts/async-vault/src/lock_policy.rs index 3f94d4e..ec51cd0 100644 --- a/contracts/async-vault/src/lock_policy.rs +++ b/contracts/async-vault/src/lock_policy.rs @@ -1,5 +1,8 @@ use soroban_sdk::{Address, Env}; -use stellar_tokens::rwa::{identity_verification::IdentityVerifierClient, RWATokenClient}; +use stellar_tokens::{ + fungible::FungibleTokenClient, + rwa::{identity_verification::IdentityVerifierClient, RWATokenClient}, +}; use crate::error::VaultError; use crate::keys::DataKey; @@ -33,6 +36,10 @@ pub(crate) enum Exit { RequestDeposit, ClaimDeposit, CancelRedeem, + CancelDeposit, + RequestRedeem, + ClaimRedeem, + ClaimWindDown, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -62,6 +69,18 @@ pub(crate) fn decide(exit: Exit, standing: Standing, winding_down: bool) -> Deci (Exit::CancelRedeem, Standing::Delisted, false) => Decision::Refuse, (Exit::CancelRedeem, Standing::Listed | Standing::Delisted, true) => Decision::Surrender, (Exit::CancelRedeem, Standing::Frozen, _) => Decision::Hold, + + (Exit::CancelDeposit, Standing::Listed | Standing::Delisted, _) => Decision::Proceed, + (Exit::CancelDeposit, Standing::Frozen, _) => Decision::Hold, + + (Exit::RequestRedeem, Standing::Listed | Standing::Delisted, _) => Decision::Proceed, + (Exit::RequestRedeem, Standing::Frozen, _) => Decision::Hold, + + (Exit::ClaimRedeem, Standing::Listed | Standing::Delisted, _) => Decision::Proceed, + (Exit::ClaimRedeem, Standing::Frozen, _) => Decision::Hold, + + (Exit::ClaimWindDown, Standing::Listed | Standing::Delisted, _) => Decision::Proceed, + (Exit::ClaimWindDown, Standing::Frozen, _) => Decision::Hold, } } @@ -72,3 +91,11 @@ pub(crate) fn refused_with(standing: Standing) -> VaultError { Standing::Listed | Standing::Delisted => VaultError::NotAllowlisted, } } + +/// Shares the holder can move: the balance less any partial freeze. +pub(crate) fn free_shares(e: &Env, holder: &Address) -> i128 { + let token = state::get_addr(e, &DataKey::ShareToken); + let balance = FungibleTokenClient::new(e, &token).balance(holder); + let frozen = RWATokenClient::new(e, &token).get_frozen_tokens(holder); + (balance - frozen).max(0) +} diff --git a/contracts/async-vault/src/redeem.rs b/contracts/async-vault/src/redeem.rs index 21d0dbd..c38490b 100644 --- a/contracts/async-vault/src/redeem.rs +++ b/contracts/async-vault/src/redeem.rs @@ -20,6 +20,23 @@ pub(crate) fn request(e: &Env, from: &Address, shares: i128) -> u64 { panic_with_error!(e, VaultError::InvalidAmount); } + let standing = lock_policy::standing(e, from); + match lock_policy::decide(Exit::RequestRedeem, standing, wind_down::is_active(e)) { + Decision::Proceed => {} + Decision::Refuse | Decision::Hold | Decision::ConvertToExit | Decision::Surrender => { + panic_with_error!(e, lock_policy::refused_with(standing)) + } + } + // A request within the balance but short of the free shares needs frozen + // ones; beyond the balance entirely, the token's own check applies instead. + if shares > lock_policy::free_shares(e, from) { + let held = + FungibleTokenClient::new(e, &state::get_addr(e, &DataKey::ShareToken)).balance(from); + if shares <= held { + panic_with_error!(e, VaultError::Frozen); + } + } + let epoch_id = state::current_epoch(e); if state::get_redeem_request(e, epoch_id, from).is_some() { @@ -77,6 +94,14 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { panic_with_error!(e, VaultError::AlreadyClaimed); } + let standing = lock_policy::standing(e, caller); + match lock_policy::decide(Exit::ClaimRedeem, standing, wind_down::is_active(e)) { + Decision::Proceed => {} + Decision::Refuse | Decision::Hold | Decision::ConvertToExit | Decision::Surrender => { + panic_with_error!(e, lock_policy::refused_with(standing)) + } + } + let assets = Pricing::redeem_assets(e, request.shares, epoch.share_price) .unwrap_or_else(|err| panic_with_error!(e, err)); @@ -157,9 +182,9 @@ 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. The -/// transfer is the token's checked one: a cancel asks the lock policy first, but -/// on a zero-asset claim the token alone refuses a frozen or delisted controller. +/// 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. 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)); diff --git a/contracts/async-vault/src/test/compliance.rs b/contracts/async-vault/src/test/compliance.rs index 4992fd1..f908835 100644 --- a/contracts/async-vault/src/test/compliance.rs +++ b/contracts/async-vault/src/test/compliance.rs @@ -177,23 +177,21 @@ fn a_delisted_holder_cannot_cancel_redemption_back_to_shares() { } #[test] -fn a_frozen_holder_cannot_cancel_redemption_back_to_shares() { +fn a_frozen_holder_is_paid_only_after_the_freeze_lifts() { let f = setup(); let user = f.holder(500); let epoch = f.vault.request_redeem(&user, &200); - - // Freeze address f.share.set_address_frozen(&user, &true, &f.compliance); - assert!(f.share.is_frozen(&user)); - // Cannot return shares to a frozen account refused(f.vault.try_cancel_redeem(&user, &epoch), VaultError::Frozen); + f.fulfill_epoch(wad(2)); + refused(f.vault.try_claim_redeem(&user, &epoch), VaultError::Frozen); + assert_eq!(f.vault.committed(), 400); assert_eq!(f.shares(&user), 300); - // Exit proceeds through cash claim once fulfilled - f.fulfill_epoch(wad(2)); + f.share.set_address_frozen(&user, &false, &f.compliance); assert_eq!(f.vault.claim_redeem(&user, &epoch), 400); - assert_eq!(f.balance(&user), 400); + assert_eq!(f.vault.committed(), 0); } #[test] @@ -267,25 +265,74 @@ fn a_frozen_investors_zero_share_refund_is_held() { } #[test] -fn a_frozen_holder_can_still_queue_an_exit() { +fn a_frozen_holder_cannot_queue_an_exit() { let f = setup(); let user = f.holder(500); - let epoch = f.vault.current_epoch(); - f.share.set_address_frozen(&user, &true, &f.compliance); - assert_eq!(f.vault.request_redeem(&user, &200), epoch); - assert_eq!(f.shares(&f.vault.address), 200); + refused(f.vault.try_request_redeem(&user, &200), VaultError::Frozen); + assert_eq!(f.shares(&user), 500); + assert_eq!(f.shares(&f.vault.address), 0); } #[test] -fn queueing_an_exit_releases_a_partial_freeze() { +fn a_partial_freeze_limits_an_exit_to_the_free_shares() { let f = setup(); let user = f.holder(500); f.share.freeze_partial_tokens(&user, &400, &f.compliance); - f.vault.request_redeem(&user, &300); + refused(f.vault.try_request_redeem(&user, &101), VaultError::Frozen); + f.vault.request_redeem(&user, &100); + + assert_eq!(f.shares(&user), 400); + assert_eq!(f.share.get_frozen_tokens(&user), 400); +} + +#[test] +fn a_frozen_investors_escrow_stays_until_the_freeze_lifts() { + let f = setup(); + let investor = f.investor(1_000); + let epoch = f.vault.request_deposit(&investor, &400); + f.share.set_address_frozen(&investor, &true, &f.compliance); + + refused( + f.vault.try_cancel_deposit(&investor, &epoch), + VaultError::Frozen, + ); + assert_eq!(f.vault.cancellable_escrow(), 400); + + f.share.set_address_frozen(&investor, &false, &f.compliance); + assert_eq!(f.vault.cancel_deposit(&investor, &epoch), 400); +} + +#[test] +fn a_frozen_holder_does_not_get_a_worthless_exit_back() { + let f = setup(); + let user = f.holder(1); + let epoch = f.vault.request_redeem(&user, &1); + f.fulfill_epoch(WAD_SCALE / 2); + f.share.set_address_frozen(&user, &true, &f.compliance); + + refused(f.vault.try_claim_redeem(&user, &epoch), VaultError::Frozen); + assert_eq!(f.vault.pending_burn_shares(), 1); + assert!(f.vault.get_redeem_request(&epoch, &user).is_some()); +} + +#[test] +fn a_frozen_holder_is_not_moved_into_the_exit_queue() { + let f = setup(); + let investor = f.investor(1_000); + let epoch = f.vault.request_deposit(&investor, &400); + f.fulfill_epoch(wad(2)); + f.identity.allow(&investor, &false, &f.compliance); + f.share.set_address_frozen(&investor, &true, &f.compliance); - assert_eq!(f.shares(&user), 200); - assert_eq!(f.share.get_frozen_tokens(&user), 200); + refused( + f.vault.try_claim_deposit(&investor, &epoch), + VaultError::Frozen, + ); + assert!(f + .vault + .get_redeem_request(&f.vault.current_epoch(), &investor) + .is_none()); } diff --git a/contracts/async-vault/src/test/lock_policy.rs b/contracts/async-vault/src/test/lock_policy.rs index 2dedef3..40133a8 100644 --- a/contracts/async-vault/src/test/lock_policy.rs +++ b/contracts/async-vault/src/test/lock_policy.rs @@ -128,6 +128,102 @@ fn in_a_wind_down_a_frozen_holders_redemption_is_held() { ); } +#[test] +fn a_listed_holder_may_cancel_a_deposit() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::CancelDeposit, Standing::Listed), + [Decision::Proceed; 2] + ); +} + +#[test] +fn a_delisted_holder_may_cancel_a_deposit() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::CancelDeposit, Standing::Delisted), + [Decision::Proceed; 2] + ); +} + +#[test] +fn a_frozen_holders_deposit_cancellation_is_held() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::CancelDeposit, Standing::Frozen), + [Decision::Hold; 2] + ); +} + +#[test] +fn a_listed_holder_may_request_a_redemption() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::RequestRedeem, Standing::Listed), + [Decision::Proceed; 2] + ); +} + +#[test] +fn a_delisted_holder_may_request_a_redemption() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::RequestRedeem, Standing::Delisted), + [Decision::Proceed; 2] + ); +} + +#[test] +fn a_frozen_holders_redemption_request_is_held() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::RequestRedeem, Standing::Frozen), + [Decision::Hold; 2] + ); +} + +#[test] +fn a_listed_holder_claims_the_redemption() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::ClaimRedeem, Standing::Listed), + [Decision::Proceed; 2] + ); +} + +#[test] +fn a_delisted_holder_claims_the_redemption() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::ClaimRedeem, Standing::Delisted), + [Decision::Proceed; 2] + ); +} + +#[test] +fn a_frozen_holders_redemption_claim_is_held() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::ClaimRedeem, Standing::Frozen), + [Decision::Hold; 2] + ); +} + +#[test] +fn a_listed_holder_claims_the_wind_down() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::ClaimWindDown, Standing::Listed), + [Decision::Proceed; 2] + ); +} + +#[test] +fn a_delisted_holder_claims_the_wind_down() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::ClaimWindDown, Standing::Delisted), + [Decision::Proceed; 2] + ); +} + +#[test] +fn a_frozen_holders_wind_down_claim_is_held() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::ClaimWindDown, Standing::Frozen), + [Decision::Hold; 2] + ); +} + #[test] fn a_frozen_holder_is_refused_as_frozen() { assert_eq!( @@ -202,3 +298,26 @@ fn standing_is_frozen_for_a_holder_both_frozen_and_delisted() { Standing::Frozen ); } + +#[test] +fn free_shares_is_the_full_balance_when_nothing_is_frozen() { + let f = setup(); + let holder = f.holder(500); + + assert_eq!( + f.e.as_contract(&f.vault.address, || lock_policy::free_shares(&f.e, &holder)), + 500 + ); +} + +#[test] +fn free_shares_excludes_a_partial_freeze() { + let f = setup(); + let holder = f.holder(500); + f.share.freeze_partial_tokens(&holder, &400, &f.compliance); + + assert_eq!( + f.e.as_contract(&f.vault.address, || lock_policy::free_shares(&f.e, &holder)), + 100 + ); +} diff --git a/contracts/async-vault/src/test/redeem.rs b/contracts/async-vault/src/test/redeem.rs index b95d8c6..87803a2 100644 --- a/contracts/async-vault/src/test/redeem.rs +++ b/contracts/async-vault/src/test/redeem.rs @@ -57,7 +57,11 @@ fn redeeming_more_shares_than_held_is_rejected() { let f = setup(); let user = f.holder(500); - assert!(f.vault.try_request_redeem(&user, &501).is_err()); + // Nothing is frozen, so the token's own balance check applies, not `Frozen`. + assert_eq!( + f.vault.try_request_redeem(&user, &501).err(), + Some(Ok(soroban_sdk::Error::from_contract_error(300))) + ); assert_eq!(f.shares(&user), 500); } diff --git a/contracts/async-vault/src/test/wind_down.rs b/contracts/async-vault/src/test/wind_down.rs index 2aef274..2a0c6cf 100644 --- a/contracts/async-vault/src/test/wind_down.rs +++ b/contracts/async-vault/src/test/wind_down.rs @@ -759,3 +759,73 @@ proptest! { prop_assert!(f.vault.wind_down_owed() < holders.len() as i128); } } + +#[test] +fn a_frozen_holders_slice_stays_owed_until_the_freeze_lifts() { + let f = setup(); + let frozen = f.holder(500); + let other = f.holder(500); + wind_down_now(&f); + f.vault.finalize_wind_down_round(); + f.share.set_address_frozen(&frozen, &true, &f.compliance); + + refused(f.vault.try_claim_wind_down(&frozen), VaultError::Frozen); + let paid = f.vault.claim_wind_down(&other); + assert_eq!(f.vault.wind_down_owed(), paid); + assert_eq!(f.shares(&frozen), 500); + + f.share.set_address_frozen(&frozen, &false, &f.compliance); + assert_eq!(f.vault.claim_wind_down(&frozen), paid); + assert_eq!(f.vault.wind_down_owed(), 0); +} + +#[test] +fn a_frozen_holders_escrowed_exit_waits_through_a_wind_down() { + let f = setup(); + let frozen = f.holder(500); + let _other = f.holder(500); + let epoch = f.vault.request_redeem(&frozen, &500); + f.share.set_address_frozen(&frozen, &true, &f.compliance); + wind_down_now(&f); + f.vault.finalize_wind_down_round(); + + refused( + f.vault.try_cancel_redeem(&frozen, &epoch), + VaultError::Frozen, + ); + refused(f.vault.try_claim_wind_down(&frozen), VaultError::Frozen); + + f.share.set_address_frozen(&frozen, &false, &f.compliance); + assert_eq!(f.vault.cancel_redeem(&frozen, &epoch), 500); + assert!(f.vault.claim_wind_down(&frozen) > 0); +} + +#[test] +fn wind_down_claimable_is_zero_for_a_frozen_holder() { + let f = setup(); + let holder = f.holder(500); + wind_down_now(&f); + f.vault.finalize_wind_down_round(); + f.share.set_address_frozen(&holder, &true, &f.compliance); + + assert_eq!(f.vault.wind_down_claimable(&holder), 0); +} + +#[test] +fn a_partial_freeze_keeps_frozen_shares_out_of_a_wind_down_claim() { + let f = setup(); + let holder = f.holder(500); + let _other = f.holder(500); + wind_down_now(&f); + f.vault.finalize_wind_down_round(); + f.share.freeze_partial_tokens(&holder, &400, &f.compliance); + + let first = f.vault.claim_wind_down(&holder); + + assert_eq!(f.shares(&holder), 400); + assert_eq!(f.share.get_frozen_tokens(&holder), 400); + f.share + .unfreeze_partial_tokens(&holder, &400, &f.compliance); + let rest = f.vault.claim_wind_down(&holder); + assert_eq!(rest, 4 * first); +} diff --git a/contracts/async-vault/src/wind_down.rs b/contracts/async-vault/src/wind_down.rs index c390e8e..1427cdd 100644 --- a/contracts/async-vault/src/wind_down.rs +++ b/contracts/async-vault/src/wind_down.rs @@ -8,6 +8,7 @@ use crate::event::{ WindDownProposed, WindDownRoundFinalized, }; use crate::keys::DataKey; +use crate::lock_policy::{self, Decision, Exit, Standing}; use crate::state; use crate::treasury; @@ -251,15 +252,17 @@ pub(crate) fn position(e: &Env, holder: &Address) -> WindDownPosition { ) } -/// What this holder would be paid if they claimed now, surrendering whatever -/// they hold. +/// What this holder would be paid if they claimed now, surrendering the free +/// shares they hold. Zero for a fully frozen holder, whom `claim` refuses. pub(crate) fn claimable(e: &Env, holder: &Address) -> i128 { if info(e).map(|wd| wd.round).unwrap_or(0) == 0 { return 0; } + if lock_policy::standing(e, holder) == Standing::Frozen { + return 0; + } - let share_token = state::get_addr(e, &DataKey::ShareToken); - let held = FungibleTokenClient::new(e, &share_token).balance(holder); + let held = lock_policy::free_shares(e, holder); let p = position(e, holder); let entitlement = p.entitlement.saturating_add(held); @@ -276,10 +279,20 @@ pub(crate) fn claim(e: &Env, holder: &Address) -> i128 { panic_with_error!(e, VaultError::DistributionNotStarted); } + let standing = lock_policy::standing(e, holder); + match lock_policy::decide(Exit::ClaimWindDown, standing, is_active(e)) { + Decision::Proceed => {} + Decision::Refuse | Decision::Hold | Decision::ConvertToExit | Decision::Surrender => { + panic_with_error!(e, lock_policy::refused_with(standing)) + } + } + let vault = e.current_contract_address(); let share_token = state::get_addr(e, &DataKey::ShareToken); - let surrendered = FungibleTokenClient::new(e, &share_token).balance(holder); + // A partial freeze keeps its shares out of the surrender; they earn every + // round regardless, and are paid once unfrozen and claimed again. + let surrendered = lock_policy::free_shares(e, holder); let mut p = position(e, holder); p.entitlement = p .entitlement diff --git a/docs/AUTHORIZATION_MATRIX.md b/docs/AUTHORIZATION_MATRIX.md index 27a565f..20198a0 100644 --- a/docs/AUTHORIZATION_MATRIX.md +++ b/docs/AUTHORIZATION_MATRIX.md @@ -8,7 +8,7 @@ This document provides the complete authorization and role model specification f 1. **Direct Role Membership Checks**: Role membership is checked directly via `has_role(e, &caller, &role)` or `stellar_macros::only_role` / `only_admin`, never by enumerating members. 2. **Strict Caller Authentication**: No entrypoint takes a caller argument that it does not authenticate. Every function accepting a `caller: Address` executes `caller.require_auth()`. -3. **Exit-Only Guarantee**: A covered redemption claim always pays out cash, regardless of pause, staleness, or allowlist status. Delisted or frozen investors exit through the cash path and are prevented from re-entering share circulation. +3. **Exit guarantee, with one hold**: A covered redemption claim pays out cash regardless of pause, staleness or allowlist status. A delisted investor exits through the cash path and cannot re-enter share circulation. A frozen investor is held: the vault refuses their requests, cancellations and claims, and pays them only after the freeze lifts. 4. **Five Separate Authorities**: - **Governance (Admin)**: Root authority; upgrades, timelock parameters, custodian settings, unpause, oracle parameters, emergency NAV pause reset. - **Manager (Vault)**: Closes epochs. diff --git a/docs/COMPLIANCE_MATRIX.md b/docs/COMPLIANCE_MATRIX.md index 67b38cd..02e8cd9 100644 --- a/docs/COMPLIANCE_MATRIX.md +++ b/docs/COMPLIANCE_MATRIX.md @@ -21,7 +21,9 @@ 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. - - A frozen investor is outside the payable-claims guarantee (design document §8.1). The vault asks the share token whether the address is frozen on `request_deposit`, `claim_deposit` and `cancel_redeem`: it refuses a frozen address's deposit request, and holds its priced deposit and escrowed redemption until the freeze is lifted (`Frozen`, 6071). Its other exits do not check freezes yet, so they still go through; the kit does not promise 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 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. --- @@ -30,11 +32,12 @@ Every investor account in the vault ecosystem exists in one of three primary com | Operation | Allowlisted | De-listed (Non-Frozen) | Frozen (Address or Partial) | Contract Mechanics & Regulatory Rationale | |---|---|---|---|---| | **`request_deposit`** | ✅ Allowed | ❌ **Refused** | ❌ **Refused** | Before any cash moves, the vault asks the share token whether the address is frozen and then asks the token's identity verifier. It refuses a frozen address (`Frozen`) and an address the token would not let hold shares (`NotAllowlisted`). A partial freeze is not checked. | -| **`cancel_deposit`** | ✅ Allowed | ✅ **Allowed** | ✅ **Allowed** | **Escape Hatch**: Calls `refund_deposit`. Escrowed settlement asset (USDC) is refunded directly to `controller`. Cash refunds bypass share token compliance checks. | +| **`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** | ⚠️ Allowed, not guaranteed | **Exit Right**: the vault escrows the shares with `RWATokenClient::forced_transfer`, which checks neither the allowlist nor freezes, so a de-listed holder can start an exit. It also releases as much of a partial freeze as the escrow needs. | +| **`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** | ⚠️ Pays, not guaranteed | **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 neither the allowlist nor freezes. | +| **`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. | +| **`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. | --- @@ -42,14 +45,14 @@ Every investor account in the vault ecosystem exists in one of three primary com ## 3. Deep Dive into Key Lifecycle Invariants ### 3.1 The Exit-Only Cash Path -A core guarantee of the Strata Vault Kit is that **a de-listed, non-frozen investor can always leave the fund in cash**. A frozen investor is outside this guarantee, as the design document states in §8.1: the cash claim does not check freezes today, so their exit goes through, but nothing promises it. +A core guarantee of the Strata Vault Kit is that **a de-listed, non-frozen investor can always leave the fund in cash**. A frozen investor is held instead of paid, as §1 describes. - **Mechanism**: 1. A de-listed holder queues an exit via `request_redeem`. The vault escrows the shares with `forced_transfer`. 2. The epoch closes and is priced via attestation. 3. Once priced and covered, the cash liability is committed. 4. The investor calls `claim_redeem`. The cash payout transfers the settlement asset (USDC) directly to the investor's Stellar account, while the shares are burned from the vault's own balance. - 5. The cash transfer never queries `IdentityVerifier` or `ShareToken::is_frozen`. Once priced and covered, cash exits cannot be blocked. + 5. The cash transfer asks the identity verifier through `standing`, but the claim does not depend on its answer: a de-listed holder is treated the same as a listed one. It does check `ShareToken::is_frozen` first: a frozen holder's claim is held, not paid, until the freeze lifts. Once priced, covered and unfrozen, cash exits cannot be blocked. ### 3.2 Deposit Cancellation for De-Listed Investors If an investor deposits into an open epoch but is de-listed (or KYC expires) before the epoch is priced: @@ -111,9 +114,15 @@ 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. -* `a_frozen_holder_cannot_cancel_redemption_back_to_shares`: Verifies frozen holders cannot abort redemption back into shares. +* `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. * `a_frozen_investor_is_not_minted_until_unfrozen`: Verifies a frozen investor's priced deposit is held until the freeze is lifted. -* `a_frozen_holder_can_still_queue_an_exit`: Records that escrowing an exit does not check freezes. -* `queueing_an_exit_releases_a_partial_freeze`: Records that the escrow releases as much of a partial freeze as it needs. +* `a_frozen_holder_cannot_queue_an_exit`: Verifies a frozen holder cannot start a redemption. +* `a_partial_freeze_limits_an_exit_to_the_free_shares`: Verifies a partial freeze caps a redemption request to the free balance. +* `a_frozen_investors_escrow_stays_until_the_freeze_lifts`: Verifies a frozen depositor's cancel is held and the escrow is unaffected. +* `a_frozen_holder_does_not_get_a_worthless_exit_back`: Verifies a frozen holder's zero-asset claim is held rather than returning shares. +* `a_frozen_holder_is_not_moved_into_the_exit_queue`: Verifies a holder who is both de-listed and frozen is held, not converted to an exit. +* `a_frozen_holders_slice_stays_owed_until_the_freeze_lifts` ([`test/wind_down.rs`](../contracts/async-vault/src/test/wind_down.rs)): Verifies a frozen holder's wind-down slice stays in `wind_down_owed` until the freeze lifts. +* `a_frozen_holders_escrowed_exit_waits_through_a_wind_down`: Verifies a frozen holder's escrowed redemption and wind-down claim are both held through a wind-down. +* `a_partial_freeze_keeps_frozen_shares_out_of_a_wind_down_claim`: Verifies a partial freeze excludes frozen shares from a wind-down surrender. diff --git a/docs/kit-spec.md b/docs/kit-spec.md index 13c9611..a65727d 100644 --- a/docs/kit-spec.md +++ b/docs/kit-spec.md @@ -42,7 +42,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 is outside this guarantee (design document §8.1). + 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`. ### Invariant 5: Exit Rights Never Gated or Pausable - **Definition**: Investors cannot be trapped in the vault by a vault pause. @@ -172,7 +172,7 @@ Strata distributes control across five native Stellar multisig authorities and p | **Guardian** | Low-threshold fast-reaction multisig | `pause` (vault); `pause_nav` (oracle emergency price freeze). | **Cannot unpause**; cannot move funds; cannot block redemption requests; cannot block payable claims or cancellations. | | **Treasury** | Operational capital multisig | `deploy_to_custodian` (moves free reserve); `fund` (returns assets from custodian). | **Cannot deploy while anything is uncovered**; cannot touch escrowed subscription assets; cannot alter parameters or pause. | | **Attester** | High-security attestation multisig | `attest` (submits NAV report with proof reference). | **Cannot price outside deviation band**; cannot violate cooldown; cannot operate on the vault directly. | -| **Compliance** | Legal / KYC operator multisig | Manage allowlist (`allow`), topic claims; token interventions (`freeze`, `forced_transfer`, `recovery`). | **Cannot touch vault reserve**; cannot pause vault operations; cannot bypass exit-only cash redemptions. | +| **Compliance** | Legal / KYC operator multisig | Manage allowlist (`allow`), topic claims; token interventions (`freeze`, `forced_transfer`, `recovery`). | **Cannot touch vault reserve**; cannot pause vault operations; cannot bypass exit-only cash redemptions for an unfrozen holder — freezing holds a covered claim until it lifts, rather than paying or redirecting it. | | **Manager** | Operational keeper account / vault | `close_epoch` (vault); `mint`, `burn`, `forced_transfer` (share token). | **Cannot choose prices**; cannot prevent epoch fulfillment; cannot alter timelocks. | | **Permissionless** | Any caller / keeper / investor | `fulfill_epoch`, `request_deposit`, `cancel_deposit`, `claim_deposit`, `request_redeem`, `cancel_redeem`, `claim_redeem`, `activate_wind_down`, `finalize_wind_down_round`, `claim_wind_down`. | Operations are strictly constrained by contract state, timing, and solvency guards. | diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index 8967577..29664a3 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -165,8 +165,10 @@ For the exhaustive access control matrix, dual `manager` role distinction, and e stale valuation can block the payment of an already-priced, funded cash claim. A pause can delay unpriced requests. Priced claims are never re-priced and never identity-gated. A delisted, non-frozen investor exits through the - exit-only cash path. Outside this guarantee, disclosed as trust assumptions: - a frozen investor, deposit-asset issuer controls, and reserve liquidity. + exit-only cash path. A frozen investor's claims are held, not paid, until + the freeze is lifted ([`COMPLIANCE_MATRIX.md`](./COMPLIANCE_MATRIX.md) §1). + Outside this guarantee, disclosed as trust assumptions: deposit-asset + issuer controls and reserve liquidity. 6. **Split accounting, with priced and payable as separate states.** The vault exposes committed (priced redemption liabilities), cancellable escrow @@ -312,16 +314,16 @@ batch boundary, though not the price it receives. - Request: moves the deposit asset into escrow; at most one active request per controller. The vault first asks the share token whether the address is frozen and asks the token's identity verifier, and refuses a frozen address or one that could not receive the shares, before any cash moves; the token checks again when it mints at claim time. - Pricing: the escrow leaves the cancellable bucket, the share quantity is set at the epoch's price, and the economic supply liability is recorded. To avoid unbounded loops that exceed Soroban transaction resource limits, physical share minting occurs lazily when each investor executes `claim_deposit`. -- Cancellation: atomic, and returns the escrowed asset in full. Open while the epoch is open. Once sealed, a readable price closes it, even while the notice still runs: an investor who can see the price cannot decline it. If the vault can no longer price the epoch — the report expires, the NAV is paused, or the vault is paused — it opens again, so escrow is never stuck behind a stalled feed. The epoch is then priced at the latest valid report at fulfil time. Cancellation also remains open during an active wind-down, allowing pending depositors to recover their escrowed assets when pricing is closed. +- Cancellation: atomic, and returns the escrowed asset in full. Open while the epoch is open. Once sealed, a readable price closes it, even while the notice still runs: an investor who can see the price cannot decline it. If the vault can no longer price the epoch — the report expires, the NAV is paused, or the vault is paused — it opens again, so escrow is never stuck behind a stalled feed. The epoch is then priced at the latest valid report at fulfil time. Cancellation also remains open during an active wind-down, allowing pending depositors to recover their escrowed assets when pricing is closed. A frozen depositor's cancellation is held until the freeze is lifted, in a wind-down too. - Share claim: verifies the receiver and delivers the shares via `mint`. A receiver off the allowlist gets no shares: they join the holder's redemption in the open epoch and are paid in cash at that epoch's price; in a wind-down they join the holder's wind-down entitlement. A frozen receiver is held until the freeze is lifted. No nominal refund exists after pricing. #### Redemption -- Request: moves shares into escrow; at most one active request per controller per epoch. +- Request: moves shares into escrow; at most one active request per controller per epoch. A frozen holder's request is held until the freeze is lifted; a partial freeze caps it to the free shares. - Cancellation: atomic, and returns the escrowed shares under the same rules as subscription cancellation; stays open during an active wind-down, where the escrow joins the holder's wind-down entitlement instead of coming back as shares. A frozen holder's cancellation is held until the freeze is lifted. - Pricing: the fixed cash liability enters committed at the epoch's price. Escrowed shares remain in the vault contract and are burned lazily as each claimant executes `claim_redeem`, preserving constant-bounded resource execution per transaction. Priced claims are never re-priced. - Coverage: a priced claim is payable when the liquid reserve covers that claim's own amount, in any order. An earlier unpaid claim never blocks a later one that is already covered. The gap between committed and liquid reserve is the uncovered amount treasury must top up. -- Cash claim: pays the fixed amount; it does not depend on identity. A delisted, non-frozen investor uses the exit-only cash path and cannot cancel back to shares. +- Cash claim: pays the fixed amount; it does not depend on identity. A delisted, non-frozen investor uses the exit-only cash path and cannot cancel back to shares. A frozen investor's claim is held until the freeze is lifted. ```mermaid sequenceDiagram