From 68527ee4a7566e8ff925232f8597ac0986748a96 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:42:48 -0300 Subject: [PATCH 1/4] feat(vault): send a delisted claim to the queue --- contracts/async-vault/src/deposit.rs | 46 ++- contracts/async-vault/src/error.rs | 2 + contracts/async-vault/src/event.rs | 19 ++ contracts/async-vault/src/keys.rs | 1 + contracts/async-vault/src/lib.rs | 12 +- contracts/async-vault/src/lock_policy.rs | 47 ++- contracts/async-vault/src/redeem.rs | 67 +++- contracts/async-vault/src/test/compliance.rs | 52 +-- contracts/async-vault/src/test/delisted.rs | 308 ++++++++++++++++++ contracts/async-vault/src/test/lock_policy.rs | 158 ++++++++- contracts/async-vault/src/test/mod.rs | 1 + contracts/async-vault/src/test/wind_down.rs | 46 ++- contracts/async-vault/src/wind_down.rs | 25 +- scripts/harness/guards.test.ts | 4 +- 14 files changed, 730 insertions(+), 58 deletions(-) create mode 100644 contracts/async-vault/src/test/delisted.rs diff --git a/contracts/async-vault/src/deposit.rs b/contracts/async-vault/src/deposit.rs index a3b1867..67f1bda 100644 --- a/contracts/async-vault/src/deposit.rs +++ b/contracts/async-vault/src/deposit.rs @@ -3,19 +3,22 @@ use stellar_tokens::rwa::RWATokenClient; use crate::epoch; use crate::error::VaultError; -use crate::event::{DepositCancelled, DepositClaimed, DepositRequested}; +use crate::event::{DepositCancelled, DepositClaimed, DepositConvertedToExit, DepositRequested}; use crate::keys::DataKey; use crate::lock_policy::{self, Decision, Exit}; use crate::pricing::{Pricing, PricingScheme}; +use crate::redeem; use crate::settlement; use crate::state::{self, DepositRequest, EpochStatus}; use crate::treasury; use crate::wind_down; -fn refuse_unless_allowlisted(e: &Env, from: &Address) { +fn refuse_unless_allowed_in(e: &Env, from: &Address) { let standing = lock_policy::standing(e, from); - if lock_policy::decide(Exit::RequestDeposit, standing) == Decision::Refuse { - panic_with_error!(e, VaultError::NotAllowlisted); + if lock_policy::decide(Exit::RequestDeposit, standing, wind_down::is_active(e)) + != Decision::Proceed + { + panic_with_error!(e, lock_policy::refused_with(standing)); } } @@ -39,7 +42,7 @@ pub(crate) fn request(e: &Env, from: &Address, amount: i128) -> u64 { if amount <= 0 { panic_with_error!(e, VaultError::InvalidAmount); } - refuse_unless_allowlisted(e, from); + refuse_unless_allowed_in(e, from); refuse_if_exceeds_cap(e, amount); let epoch_id = state::current_epoch(e); @@ -99,6 +102,12 @@ 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); + let decision = lock_policy::decide(Exit::ClaimDeposit, standing, wind_down::is_active(e)); + if matches!(decision, Decision::Refuse | Decision::Hold) { + panic_with_error!(e, lock_policy::refused_with(standing)); + } + let shares = Pricing::deposit_shares(e, request.amount, epoch.share_price) .unwrap_or_else(|err| panic_with_error!(e, err)); @@ -118,6 +127,11 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { state::set_deposit_request(e, epoch_id, caller, &request); state::set_pending_mint_shares(e, state::reduce(e, state::pending_mint_shares(e), shares)); + if decision != Decision::Proceed { + convert_to_exit(e, caller, epoch_id, shares, decision); + return 0; + } + let share_token = state::get_addr(e, &DataKey::ShareToken); RWATokenClient::new(e, &share_token).mint(caller, &shares, &e.current_contract_address()); @@ -133,6 +147,28 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { shares } +/// The token refuses this holder shares, so they exit in cash: next price, or the wind-down payout. +fn convert_to_exit(e: &Env, holder: &Address, epoch_id: u64, shares: i128, decision: Decision) { + let into_epoch = if decision == Decision::Surrender { + wind_down::credit(e, holder, shares); + 0 + } else { + let vault = e.current_contract_address(); + let share_token = state::get_addr(e, &DataKey::ShareToken); + RWATokenClient::new(e, &share_token).mint(&vault, &shares, &vault); + redeem::queue_escrowed(e, holder, shares) + }; + + DepositConvertedToExit { + controller: holder.clone(), + epoch: epoch_id, + into_epoch, + shares, + } + .publish(e); + settlement::on_deposit_claimed(e, epoch_id, shares); +} + /// Returns the settlement asset and clears the request. Used by cancellation and /// by a claim whose conversion floors to zero. fn refund_deposit(e: &Env, controller: &Address, epoch_id: u64, amount: i128) { diff --git a/contracts/async-vault/src/error.rs b/contracts/async-vault/src/error.rs index 7f84ef7..47ab3c2 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -121,4 +121,6 @@ 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: nothing moves for them until the freeze is lifted. + Frozen = 6071, } diff --git a/contracts/async-vault/src/event.rs b/contracts/async-vault/src/event.rs index 3d76cbc..33c55f6 100644 --- a/contracts/async-vault/src/event.rs +++ b/contracts/async-vault/src/event.rs @@ -149,6 +149,25 @@ pub struct WindDownClaimed { pub assets: i128, } +/// An escrowed redemption burned into the controller's wind-down entitlement. +#[contractevent] +pub struct RedemptionSurrendered { + #[topic] + pub controller: Address, + pub epoch: u64, + pub shares: i128, +} + +/// A priced deposit queued to exit in `into_epoch`; zero means the wind-down entitlement. +#[contractevent] +pub struct DepositConvertedToExit { + #[topic] + pub controller: Address, + pub epoch: u64, + pub into_epoch: u64, + pub shares: i128, +} + #[contractevent] pub struct EpochReconciled { #[topic] diff --git a/contracts/async-vault/src/keys.rs b/contracts/async-vault/src/keys.rs index 0f6d115..ad78eec 100644 --- a/contracts/async-vault/src/keys.rs +++ b/contracts/async-vault/src/keys.rs @@ -23,6 +23,7 @@ pub(crate) enum DataKey { WindDownAcc, WindDownOwed, WindDownPosition(Address), + WindDownCredited, DepositCap, Settlement(u64), } diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 7d3173e..5b5b3f2 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -32,9 +32,10 @@ use state::FIRST_EPOCH; pub use error::VaultError; pub use event::{ ChangeApplied, ChangeCancelled, ChangeProposed, CustodianSet, Deployed, DepositCapUpdated, - DepositClaimed, DepositRequested, EpochClosed, EpochFulfilled, EpochReconciled, Funded, - NoticeSet, RedeemClaimed, RedeemRequested, WindDownActivated, WindDownClaimed, - WindDownDelaySet, WindDownProposalCancelled, WindDownProposed, WindDownRoundFinalized, + DepositClaimed, DepositConvertedToExit, DepositRequested, EpochClosed, EpochFulfilled, + EpochReconciled, Funded, NoticeSet, RedeemClaimed, RedeemRequested, RedemptionSurrendered, + WindDownActivated, WindDownClaimed, WindDownDelaySet, WindDownProposalCancelled, + WindDownProposed, WindDownRoundFinalized, }; pub use governance::{VaultChange, VaultPendingChange}; pub use pricing::{DirectUnitPricing, PricingScheme}; @@ -352,8 +353,9 @@ impl AsyncVault { deposit::cancel(e, &from, epoch_id) } - /// Cancels an unpriced redemption. Refused for a controller the share token - /// will not let hold shares; that controller exits through the cash claim. + /// 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. 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/lock_policy.rs b/contracts/async-vault/src/lock_policy.rs index e0704e0..3f94d4e 100644 --- a/contracts/async-vault/src/lock_policy.rs +++ b/contracts/async-vault/src/lock_policy.rs @@ -1,6 +1,7 @@ use soroban_sdk::{Address, Env}; use stellar_tokens::rwa::{identity_verification::IdentityVerifierClient, RWATokenClient}; +use crate::error::VaultError; use crate::keys::DataKey; use crate::state; @@ -9,13 +10,17 @@ use crate::state; pub(crate) enum Standing { Listed, Delisted, + Frozen, } -/// The holder's standing, asked of the identity verifier the share token -/// itself uses, so the vault and the token never disagree. +/// The holder's standing, asked of the share token and its identity verifier, +/// so the vault and the token never disagree. pub(crate) fn standing(e: &Env, holder: &Address) -> Standing { - let token = state::get_addr(e, &DataKey::ShareToken); - let verifier = RWATokenClient::new(e, &token).identity_verifier(); + let token = RWATokenClient::new(e, &state::get_addr(e, &DataKey::ShareToken)); + if token.is_frozen(holder) { + return Standing::Frozen; + } + let verifier = token.identity_verifier(); match IdentityVerifierClient::new(e, &verifier).try_verify_identity(holder) { Ok(Ok(())) => Standing::Listed, _ => Standing::Delisted, @@ -26,18 +31,44 @@ pub(crate) fn standing(e: &Env, holder: &Address) -> Standing { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum Exit { RequestDeposit, + ClaimDeposit, + CancelRedeem, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum Decision { Proceed, Refuse, + /// The priced shares join the holder's redemption in the open epoch. + ConvertToExit, + /// The shares join the holder's wind-down entitlement. + Surrender, + /// Nothing moves until the freeze is lifted. + Hold, } /// What `exit` does for a holder in `standing`. No storage, no calls. -pub(crate) fn decide(exit: Exit, standing: Standing) -> Decision { - match (exit, standing) { - (Exit::RequestDeposit, Standing::Listed) => Decision::Proceed, - (Exit::RequestDeposit, Standing::Delisted) => Decision::Refuse, +pub(crate) fn decide(exit: Exit, standing: Standing, winding_down: bool) -> Decision { + match (exit, standing, winding_down) { + (Exit::RequestDeposit, Standing::Listed, _) => Decision::Proceed, + (Exit::RequestDeposit, Standing::Delisted | Standing::Frozen, _) => Decision::Refuse, + + (Exit::ClaimDeposit, Standing::Listed, _) => Decision::Proceed, + (Exit::ClaimDeposit, Standing::Delisted, false) => Decision::ConvertToExit, + (Exit::ClaimDeposit, Standing::Delisted, true) => Decision::Surrender, + (Exit::ClaimDeposit, Standing::Frozen, _) => Decision::Hold, + + (Exit::CancelRedeem, Standing::Listed, false) => Decision::Proceed, + (Exit::CancelRedeem, Standing::Delisted, false) => Decision::Refuse, + (Exit::CancelRedeem, Standing::Listed | Standing::Delisted, true) => Decision::Surrender, + (Exit::CancelRedeem, Standing::Frozen, _) => Decision::Hold, + } +} + +/// The error an exit fails with when `decide` refuses or holds it. +pub(crate) fn refused_with(standing: Standing) -> VaultError { + match standing { + Standing::Frozen => VaultError::Frozen, + Standing::Listed | Standing::Delisted => VaultError::NotAllowlisted, } } diff --git a/contracts/async-vault/src/redeem.rs b/contracts/async-vault/src/redeem.rs index d2c1969..ff612bb 100644 --- a/contracts/async-vault/src/redeem.rs +++ b/contracts/async-vault/src/redeem.rs @@ -3,8 +3,9 @@ use stellar_tokens::{fungible::FungibleTokenClient, rwa::RWATokenClient}; use crate::epoch; use crate::error::VaultError; -use crate::event::{RedeemCancelled, RedeemClaimed, RedeemRequested}; +use crate::event::{RedeemCancelled, RedeemClaimed, RedeemRequested, RedemptionSurrendered}; use crate::keys::DataKey; +use crate::lock_policy::{self, Decision, Exit}; use crate::pricing::{Pricing, PricingScheme}; use crate::settlement; use crate::state::{self, EpochStatus, RedeemRequest}; @@ -122,6 +123,37 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { assets } +/// Queues shares the vault already holds, adding to any redemption open in the epoch. +pub(crate) fn queue_escrowed(e: &Env, controller: &Address, shares: i128) -> u64 { + let epoch_id = state::current_epoch(e); + let mut epoch = state::get_epoch(e, epoch_id) + .unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound)); + epoch.total_shares_redeeming = epoch + .total_shares_redeeming + .checked_add(shares) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); + + let request = match state::get_redeem_request(e, epoch_id, controller) { + Some(mut open) => { + open.shares = open + .shares + .checked_add(shares) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); + open + } + None => { + settlement::on_redeem_requested(e, epoch_id); + RedeemRequest { + shares, + claimed: false, + } + } + }; + state::set_redeem_request(e, epoch_id, controller, &request); + state::set_epoch(e, epoch_id, &epoch); + epoch_id +} + /// Moves escrowed shares back to the controller and clears the request. The /// checked transfer is deliberate: the share token decides whether this holder /// may receive a regulated share, so a frozen or de-listed controller is refused @@ -155,18 +187,47 @@ pub(crate) fn cancel(e: &Env, from: &Address, epoch_id: u64) -> i128 { panic_with_error!(e, VaultError::AlreadyPriced); } + let winding_down = wind_down::is_active(e); + // Cancellation is an escape hatch, not a choice. Once the feed can price a // sealed epoch, the price is knowable and the only way out is to take it. // Fulfilment is closed during a wind-down, so this epoch will never take a // price. There is nothing to decline. - if !wind_down::is_active(e) && epoch::is_priceable(e, &epoch) { + if !winding_down && epoch::is_priceable(e, &epoch) { panic_with_error!(e, VaultError::PriceAvailable); } let request = state::get_redeem_request(e, epoch_id, from) .unwrap_or_else(|| panic_with_error!(e, VaultError::RequestNotFound)); - return_shares(e, from, epoch_id, request.shares); + let standing = lock_policy::standing(e, from); + match lock_policy::decide(Exit::CancelRedeem, standing, winding_down) { + Decision::Proceed => return_shares(e, from, epoch_id, request.shares), + Decision::Surrender => surrender_escrow(e, from, epoch_id, request.shares), + Decision::Refuse | Decision::Hold | Decision::ConvertToExit => { + panic_with_error!(e, lock_policy::refused_with(standing)) + } + } settlement::on_redeem_cancelled(e, epoch_id); request.shares } + +/// Burns the escrow into the entitlement, so no token pause or allowlist place can hold it. +fn surrender_escrow(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)); + epoch.total_shares_redeeming = state::reduce(e, epoch.total_shares_redeeming, shares); + state::set_epoch(e, epoch_id, &epoch); + state::remove_redeem_request(e, epoch_id, controller); + + let vault = e.current_contract_address(); + RWATokenClient::new(e, &state::get_addr(e, &DataKey::ShareToken)).burn(&vault, &shares, &vault); + wind_down::credit(e, controller, shares); + + RedemptionSurrendered { + controller: controller.clone(), + epoch: epoch_id, + shares, + } + .publish(e); +} diff --git a/contracts/async-vault/src/test/compliance.rs b/contracts/async-vault/src/test/compliance.rs index ff24264..7c42c25 100644 --- a/contracts/async-vault/src/test/compliance.rs +++ b/contracts/async-vault/src/test/compliance.rs @@ -49,7 +49,6 @@ fn relisting_after_pricing_lets_the_claim_through() { f.vault.request_deposit(&investor, &400); f.fulfill_epoch(wad(2)); f.identity.allow(&investor, &false, &f.compliance); - assert!(f.vault.try_claim_deposit(&investor, &1).is_err()); f.identity.allow(&investor, &true, &f.compliance); @@ -57,25 +56,17 @@ fn relisting_after_pricing_lets_the_claim_through() { assert_eq!(f.shares(&investor), 200); } -/// Today's behaviour for a holder delisted after their deposit was priced: -/// relisting is the only way out. The cash joined the reserve at pricing, so -/// her claim is to shares, and the wind-down holds her part in `owed` where -/// nobody can collect it. +/// Delisted after pricing, she cannot cancel, and a wind-down still pays her slice. #[test] -fn a_holder_delisted_after_pricing_can_only_leave_by_being_relisted() { +fn a_holder_delisted_after_pricing_is_paid_through_a_wind_down() { let f = setup(); let investor = f.investor(1_000); let epoch = f.vault.request_deposit(&investor, &400); f.fulfill_epoch(wad(2)); assert_eq!(f.vault.cancellable_escrow(), 0); assert_eq!(f.vault.pending_mint_shares(), 200); - f.identity.allow(&investor, &false, &f.compliance); - assert_eq!( - f.vault.try_claim_deposit(&investor, &epoch).err(), - Some(Ok(soroban_sdk::Error::from_contract_error(304))) - ); refused( f.vault.try_cancel_deposit(&investor, &epoch), VaultError::AlreadyPriced, @@ -83,15 +74,8 @@ fn a_holder_delisted_after_pricing_can_only_leave_by_being_relisted() { f.vault.propose_wind_down(&f.admin); f.vault.activate_wind_down(); + assert_eq!(f.vault.claim_deposit(&investor, &epoch), 0); assert_eq!(f.vault.finalize_wind_down_round(), 400); - refused( - f.vault.try_claim_wind_down(&investor), - VaultError::NoEntitlement, - ); - assert_eq!(f.vault.wind_down_owed(), 400); - - f.identity.allow(&investor, &true, &f.compliance); - assert_eq!(f.vault.claim_deposit(&investor, &epoch), 200); assert_eq!(f.vault.claim_wind_down(&investor), 400); assert_eq!(f.vault.wind_down_owed(), 0); } @@ -180,8 +164,10 @@ fn a_delisted_holder_cannot_cancel_redemption_back_to_shares() { // Delisted before pricing f.identity.allow(&user, &false, &f.compliance); - // Cannot return shares to a delisted account (refused by share transfer compliance) - assert!(f.vault.try_cancel_redeem(&user, &epoch).is_err()); + refused( + f.vault.try_cancel_redeem(&user, &epoch), + VaultError::NotAllowlisted, + ); assert_eq!(f.shares(&user), 300); // Exit proceeds through cash claim once fulfilled @@ -201,7 +187,7 @@ fn a_frozen_holder_cannot_cancel_redemption_back_to_shares() { assert!(f.share.is_frozen(&user)); // Cannot return shares to a frozen account - assert!(f.vault.try_cancel_redeem(&user, &epoch).is_err()); + refused(f.vault.try_cancel_redeem(&user, &epoch), VaultError::Frozen); assert_eq!(f.shares(&user), 300); // Exit proceeds through cash claim once fulfilled @@ -231,16 +217,32 @@ fn a_frozen_or_delisted_holder_cannot_transfer_shares() { } #[test] -fn a_frozen_investor_on_the_allowlist_is_still_minted() { +fn a_frozen_investor_cannot_request_a_deposit() { + let f = setup(); + let investor = f.investor(1_000); + f.share.set_address_frozen(&investor, &true, &f.compliance); + + refused( + f.vault.try_request_deposit(&investor, &400), + VaultError::Frozen, + ); + assert_eq!(f.balance(&investor), 1_000); +} + +#[test] +fn a_frozen_investor_is_not_minted_until_unfrozen() { let f = setup(); let investor = f.investor(1_000); f.vault.request_deposit(&investor, &400); f.fulfill_epoch(wad(2)); - f.share.set_address_frozen(&investor, &true, &f.compliance); + refused(f.vault.try_claim_deposit(&investor, &1), VaultError::Frozen); + assert_eq!(f.shares(&investor), 0); + assert!(!f.vault.get_deposit_request(&1, &investor).unwrap().claimed); + + f.share.set_address_frozen(&investor, &false, &f.compliance); assert_eq!(f.vault.claim_deposit(&investor, &1), 200); - assert_eq!(f.shares(&investor), 200); } #[test] diff --git a/contracts/async-vault/src/test/delisted.rs b/contracts/async-vault/src/test/delisted.rs new file mode 100644 index 0000000..8209ed9 --- /dev/null +++ b/contracts/async-vault/src/test/delisted.rs @@ -0,0 +1,308 @@ +use proptest::prelude::*; +use soroban_sdk::{testutils::Events as _, Event as _}; + +use super::*; +use crate::{DepositConvertedToExit, EpochReconciled, RedemptionSurrendered}; + +/// A 400 deposit priced at 2.0 for 200 shares, then delisted before the claim. +fn delisted_with_a_priced_deposit(f: &Fixture) -> (Address, u64) { + 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); + (investor, epoch) +} + +#[test] +fn a_delisted_holders_priced_deposit_joins_their_redemption_in_the_open_epoch() { + let f = setup(); + let (investor, epoch) = delisted_with_a_priced_deposit(&f); + let open = f.vault.current_epoch(); + + assert_eq!(f.vault.claim_deposit(&investor, &epoch), 0); + + assert_eq!( + f.e.events().all().filter_by_contract(&f.vault.address), + [ + DepositConvertedToExit { + controller: investor.clone(), + epoch, + into_epoch: open, + shares: 200, + } + .to_xdr(&f.e, &f.vault.address), + EpochReconciled { + epoch, + share_dust: 0, + cash_dust: 0, + } + .to_xdr(&f.e, &f.vault.address), + ] + ); + assert_eq!(f.shares(&investor), 0); + assert_eq!(f.shares(&f.vault.address), 200); + assert_eq!(f.vault.pending_mint_shares(), 0); + assert_eq!( + f.vault.get_redeem_request(&open, &investor).unwrap().shares, + 200 + ); + refused( + f.vault.try_claim_deposit(&investor, &epoch), + VaultError::AlreadyClaimed, + ); +} + +/// A fall since her deposit priced is hers, as it would be for anyone leaving. +#[test] +fn the_delisted_holder_is_paid_at_the_next_epochs_price() { + let f = setup(); + let (investor, epoch) = delisted_with_a_priced_deposit(&f); + f.vault.claim_deposit(&investor, &epoch); + + let exit = f.fulfill_epoch(wad(8) / 5); + + assert_eq!(f.vault.claim_redeem(&investor, &exit), 320); + assert_eq!(f.balance(&investor), 600 + 320); +} + +#[test] +fn the_delisted_holder_exits_at_the_same_price_as_everyone_in_that_epoch() { + let f = setup(); + let other = f.holder(200); + let (investor, epoch) = delisted_with_a_priced_deposit(&f); + f.vault.claim_deposit(&investor, &epoch); + f.vault.request_redeem(&other, &200); + + let exit = f.fulfill_epoch(wad(8) / 5); + + assert_eq!(f.vault.claim_redeem(&investor, &exit), 320); + assert_eq!(f.vault.claim_redeem(&other, &exit), 320); +} + +#[test] +fn a_conversion_adds_to_a_redemption_already_open_in_the_epoch() { + let f = setup(); + let investor = f.holder(100); + StellarAssetClient::new(&f.e, &f.asset).mint(&investor, &400); + let epoch = f.vault.request_deposit(&investor, &400); + f.fulfill_epoch(wad(2)); + let open = f.vault.current_epoch(); + f.vault.request_redeem(&investor, &100); + f.identity.allow(&investor, &false, &f.compliance); + + f.vault.claim_deposit(&investor, &epoch); + + assert_eq!( + f.vault.get_redeem_request(&open, &investor).unwrap().shares, + 300 + ); + assert_eq!( + f.vault.get_epoch(&open).unwrap().total_shares_redeeming, + 300 + ); + assert_eq!(f.vault.get_epoch_settlement(&open).unwrap().redeems_open, 1); +} + +#[test] +fn in_a_wind_down_the_shares_are_credited_before_the_first_round() { + let f = setup(); + let (investor, epoch) = delisted_with_a_priced_deposit(&f); + f.vault.propose_wind_down(&f.admin); + f.vault.activate_wind_down(); + + assert_eq!(f.vault.claim_deposit(&investor, &epoch), 0); + assert_eq!(f.vault.finalize_wind_down_round(), 400); + + assert_eq!(f.vault.claim_wind_down(&investor), 400); + assert_eq!(f.vault.wind_down_owed(), 0); +} + +#[test] +fn in_a_wind_down_the_shares_are_credited_after_the_first_round() { + let f = setup(); + let (investor, epoch) = delisted_with_a_priced_deposit(&f); + f.vault.propose_wind_down(&f.admin); + f.vault.activate_wind_down(); + assert_eq!(f.vault.finalize_wind_down_round(), 400); + + f.vault.claim_deposit(&investor, &epoch); + + assert_eq!(f.vault.claim_wind_down(&investor), 400); + assert_eq!(f.vault.wind_down_owed(), 0); +} + +/// Without the credit in the snapshot, the first round would owe more than the vault holds. +#[test] +fn a_credit_before_the_first_round_leaves_every_holder_their_share() { + let f = setup(); + let other = f.holder(200); + let (investor, epoch) = delisted_with_a_priced_deposit(&f); + f.vault.propose_wind_down(&f.admin); + f.vault.activate_wind_down(); + f.vault.claim_deposit(&investor, &epoch); + + assert_eq!(f.vault.finalize_wind_down_round(), 800); + assert_eq!(f.vault.claim_wind_down(&investor), 400); + assert_eq!(f.vault.claim_wind_down(&other), 400); + assert_eq!(f.balance(&f.vault.address), 0); +} + +#[test] +fn a_frozen_and_delisted_holders_claim_is_held() { + let f = setup(); + let (investor, epoch) = delisted_with_a_priced_deposit(&f); + f.share.set_address_frozen(&investor, &true, &f.compliance); + + refused( + f.vault.try_claim_deposit(&investor, &epoch), + VaultError::Frozen, + ); + assert_eq!(f.vault.pending_mint_shares(), 200); + assert!( + !f.vault + .get_deposit_request(&epoch, &investor) + .unwrap() + .claimed + ); +} + +/// Converted while the wind-down was only proposed, the redemption will never be priced. +#[test] +fn a_conversion_caught_by_a_wind_down_is_paid_from_the_rounds() { + let f = setup(); + let other = f.holder(200); + let (investor, epoch) = delisted_with_a_priced_deposit(&f); + f.vault.propose_wind_down(&f.admin); + f.vault.claim_deposit(&investor, &epoch); + let open = f.vault.current_epoch(); + f.vault.activate_wind_down(); + + assert_eq!(f.vault.cancel_redeem(&investor, &open), 200); + + assert_eq!( + f.e.events().all().filter_by_contract(&f.vault.address), + [RedemptionSurrendered { + controller: investor.clone(), + epoch: open, + shares: 200, + } + .to_xdr(&f.e, &f.vault.address)] + ); + assert_eq!(f.vault.get_redeem_request(&open, &investor), None); + assert_eq!(f.shares(&f.vault.address), 0); + assert_eq!(f.vault.finalize_wind_down_round(), 800); + assert_eq!(f.vault.claim_wind_down(&investor), 400); + assert_eq!(f.vault.claim_wind_down(&other), 400); + assert_eq!(f.balance(&f.vault.address), 0); +} + +#[test] +fn a_redemption_surrendered_after_the_first_round_keeps_the_rounds_solvent() { + let f = setup(); + let other = f.holder(200); + let (investor, epoch) = delisted_with_a_priced_deposit(&f); + f.vault.propose_wind_down(&f.admin); + f.vault.claim_deposit(&investor, &epoch); + let open = f.vault.current_epoch(); + f.vault.activate_wind_down(); + assert_eq!(f.vault.finalize_wind_down_round(), 800); + + f.vault.cancel_redeem(&investor, &open); + + assert_eq!(f.vault.claim_wind_down(&investor), 400); + assert_eq!(f.vault.claim_wind_down(&other), 400); + assert_eq!(f.vault.wind_down_owed(), 0); +} + +#[test] +fn a_delisted_holders_unpriced_redemption_joins_the_wind_down_when_cancelled() { + let f = setup(); + let investor = f.holder(200); + let other = f.holder(200); + let epoch = f.vault.request_redeem(&investor, &200); + f.identity.allow(&investor, &false, &f.compliance); + f.vault.propose_wind_down(&f.admin); + f.vault.activate_wind_down(); + + assert_eq!(f.vault.cancel_redeem(&investor, &epoch), 200); + + assert_eq!(f.vault.finalize_wind_down_round(), 800); + assert_eq!(f.vault.claim_wind_down(&investor), 400); + assert_eq!(f.vault.claim_wind_down(&other), 400); +} + +#[test] +fn a_conversion_waits_while_the_vault_is_delisted() { + let f = setup(); + let investor = f.investor(1_000); + let epoch = f.vault.request_deposit(&investor, &1_000); + f.fulfill_epoch(wad(2)); + f.identity.allow(&investor, &false, &f.compliance); + f.identity.allow(&f.vault.address, &false, &f.compliance); + + assert_eq!( + f.vault.try_claim_deposit(&investor, &epoch), + Err(Ok(soroban_sdk::Error::from_contract_error(304))) + ); + assert!(f.vault.get_deposit_request(&epoch, &investor).is_some()); + + f.identity.allow(&f.vault.address, &true, &f.compliance); + assert_eq!(f.vault.claim_deposit(&investor, &epoch), 0); + let open = f.vault.current_epoch(); + assert_eq!( + f.vault.get_redeem_request(&open, &investor).unwrap().shares, + 500 + ); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(16))] + + /// Conversions in any order among the other claims still settle the epoch. + #[test] + fn conversions_settle_the_epoch_in_any_order( + deposits in proptest::collection::vec(1i128..5_000, 1..5), + delisted in proptest::collection::vec(any::(), 4), + order in proptest::collection::vec(any::(), 4), + price_tenths in 5i128..=20, + ) { + let f = setup(); + let investors: std::vec::Vec
= deposits + .iter() + .map(|amount| { + let who = f.investor(*amount); + f.vault.request_deposit(&who, amount); + who + }) + .collect(); + let epoch = f.fulfill_epoch(wad(1) * price_tenths / 10); + let open = f.vault.current_epoch(); + + let mut claims: std::vec::Vec<(u16, usize)> = + (0..investors.len()).map(|i| (order[i], i)).collect(); + claims.sort(); + let mut converted = 0i128; + for (_, i) in claims { + let who = &investors[i]; + if delisted[i] { + f.identity.allow(who, &false, &f.compliance); + } + let queued = || f.vault.get_redeem_request(&open, who).map_or(0, |r| r.shares); + let before = queued(); + let paid = f.vault.claim_deposit(who, &epoch); + if delisted[i] { + prop_assert_eq!(paid, 0); + converted += queued() - before; + } + } + + let s = f.vault.get_epoch_settlement(&epoch).unwrap(); + prop_assert!(s.reconciled); + prop_assert_eq!(f.vault.pending_mint_shares(), 0); + prop_assert_eq!( + f.vault.get_epoch(&open).unwrap().total_shares_redeeming, + converted + ); + prop_assert_eq!(f.shares(&f.vault.address), converted); + } +} diff --git a/contracts/async-vault/src/test/lock_policy.rs b/contracts/async-vault/src/test/lock_policy.rs index a323d47..2dedef3 100644 --- a/contracts/async-vault/src/test/lock_policy.rs +++ b/contracts/async-vault/src/test/lock_policy.rs @@ -1,22 +1,149 @@ use super::*; use crate::lock_policy::{self, Decision, Exit, Standing}; +fn in_and_out_of_a_wind_down(exit: Exit, standing: Standing) -> [Decision; 2] { + [ + lock_policy::decide(exit, standing, false), + lock_policy::decide(exit, standing, true), + ] +} + #[test] fn a_listed_holder_may_request_a_deposit() { assert_eq!( - lock_policy::decide(Exit::RequestDeposit, Standing::Listed), - Decision::Proceed + in_and_out_of_a_wind_down(Exit::RequestDeposit, Standing::Listed), + [Decision::Proceed; 2] ); } #[test] fn a_delisted_holder_may_not_request_a_deposit() { assert_eq!( - lock_policy::decide(Exit::RequestDeposit, Standing::Delisted), + in_and_out_of_a_wind_down(Exit::RequestDeposit, Standing::Delisted), + [Decision::Refuse; 2] + ); +} + +#[test] +fn a_frozen_holder_may_not_request_a_deposit() { + assert_eq!( + in_and_out_of_a_wind_down(Exit::RequestDeposit, Standing::Frozen), + [Decision::Refuse; 2] + ); +} + +#[test] +fn a_listed_holder_claims_the_shares() { + assert_eq!( + lock_policy::decide(Exit::ClaimDeposit, Standing::Listed, false), + Decision::Proceed + ); +} + +#[test] +fn a_delisted_holders_claim_joins_the_exit_queue() { + assert_eq!( + lock_policy::decide(Exit::ClaimDeposit, Standing::Delisted, false), + Decision::ConvertToExit + ); +} + +#[test] +fn a_frozen_holders_claim_is_held() { + assert_eq!( + lock_policy::decide(Exit::ClaimDeposit, Standing::Frozen, false), + Decision::Hold + ); +} + +#[test] +fn in_a_wind_down_a_listed_holder_claims_the_shares() { + assert_eq!( + lock_policy::decide(Exit::ClaimDeposit, Standing::Listed, true), + Decision::Proceed + ); +} + +#[test] +fn in_a_wind_down_a_delisted_holders_claim_joins_the_entitlement() { + assert_eq!( + lock_policy::decide(Exit::ClaimDeposit, Standing::Delisted, true), + Decision::Surrender + ); +} + +#[test] +fn in_a_wind_down_a_frozen_holders_claim_is_held() { + assert_eq!( + lock_policy::decide(Exit::ClaimDeposit, Standing::Frozen, true), + Decision::Hold + ); +} + +#[test] +fn a_listed_holder_may_cancel_a_redemption_back_to_shares() { + assert_eq!( + lock_policy::decide(Exit::CancelRedeem, Standing::Listed, false), + Decision::Proceed + ); +} + +#[test] +fn a_delisted_holder_may_not_cancel_a_redemption_back_to_shares() { + assert_eq!( + lock_policy::decide(Exit::CancelRedeem, Standing::Delisted, false), Decision::Refuse ); } +#[test] +fn a_frozen_holders_redemption_is_held() { + assert_eq!( + lock_policy::decide(Exit::CancelRedeem, Standing::Frozen, false), + Decision::Hold + ); +} + +#[test] +fn in_a_wind_down_a_listed_holders_cancelled_redemption_joins_the_entitlement() { + assert_eq!( + lock_policy::decide(Exit::CancelRedeem, Standing::Listed, true), + Decision::Surrender + ); +} + +#[test] +fn in_a_wind_down_a_delisted_holders_cancelled_redemption_joins_the_entitlement() { + assert_eq!( + lock_policy::decide(Exit::CancelRedeem, Standing::Delisted, true), + Decision::Surrender + ); +} + +#[test] +fn in_a_wind_down_a_frozen_holders_redemption_is_held() { + assert_eq!( + lock_policy::decide(Exit::CancelRedeem, Standing::Frozen, true), + Decision::Hold + ); +} + +#[test] +fn a_frozen_holder_is_refused_as_frozen() { + assert_eq!( + lock_policy::refused_with(Standing::Frozen), + VaultError::Frozen + ); +} + +#[test] +fn a_delisted_holder_is_refused_as_not_allowlisted() { + assert_eq!( + lock_policy::refused_with(Standing::Delisted), + VaultError::NotAllowlisted + ); +} + #[test] fn standing_is_listed_for_an_allowlisted_holder() { let f = setup(); @@ -50,3 +177,28 @@ fn standing_is_delisted_for_a_holder_listed_then_removed() { Standing::Delisted ); } + +#[test] +fn standing_is_frozen_for_a_frozen_holder_on_the_allowlist() { + let f = setup(); + let holder = f.investor(0); + f.share.set_address_frozen(&holder, &true, &f.compliance); + + assert_eq!( + f.e.as_contract(&f.vault.address, || lock_policy::standing(&f.e, &holder)), + Standing::Frozen + ); +} + +#[test] +fn standing_is_frozen_for_a_holder_both_frozen_and_delisted() { + let f = setup(); + let holder = f.investor(0); + f.identity.allow(&holder, &false, &f.compliance); + f.share.set_address_frozen(&holder, &true, &f.compliance); + + assert_eq!( + f.e.as_contract(&f.vault.address, || lock_policy::standing(&f.e, &holder)), + Standing::Frozen + ); +} diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index 38d4853..b807f91 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -3,6 +3,7 @@ mod compliance; mod constructor; mod controls; mod conversions; +mod delisted; mod deposit; mod deposit_cap; mod epochs; diff --git a/contracts/async-vault/src/test/wind_down.rs b/contracts/async-vault/src/test/wind_down.rs index 73056f0..b79b979 100644 --- a/contracts/async-vault/src/test/wind_down.rs +++ b/contracts/async-vault/src/test/wind_down.rs @@ -249,7 +249,7 @@ fn an_epoch_sealed_before_the_announcement_is_still_cancellable() { } #[test] -fn an_escrowed_redemption_is_still_cancellable_after_the_announcement() { +fn an_escrowed_redemption_cancelled_in_a_wind_down_joins_the_payout() { let f = setup(); let user = f.holder(500); let epoch = f.vault.request_redeem(&user, &200); @@ -258,7 +258,10 @@ fn an_escrowed_redemption_is_still_cancellable_after_the_announcement() { wind_down_now(&f); assert_eq!(f.vault.cancel_redeem(&user, &epoch), 200); - assert_eq!(f.shares(&user), 500); + assert_eq!(f.shares(&user), 300); + + let pot = f.vault.finalize_wind_down_round(); + assert_eq!(f.vault.claim_wind_down(&user), pot); } #[test] @@ -439,20 +442,51 @@ fn a_holder_with_no_shares_cannot_claim() { } #[test] -fn an_escrowed_redemption_is_recovered_then_surrendered() { +fn a_cancelled_redemption_joins_the_wind_down_payout() { let f = setup(); let user = f.holder(200); let epoch = f.vault.request_redeem(&user, &200); wind_down_now(&f); f.vault.finalize_wind_down_round(); - // The escrowed shares are inside the snapshot, so they must come back first. assert!(f.vault.try_claim_wind_down(&user).is_err()); f.vault.cancel_redeem(&user, &epoch); - let rest = f.vault.claim_wind_down(&user); - assert!(rest > 0); assert_eq!(f.shares(&user), 0); + assert!(f.vault.claim_wind_down(&user) > 0); +} + +#[test] +fn a_token_pause_does_not_hold_a_redemption_in_a_wind_down() { + let f = setup(); + let user = f.holder(200); + let epoch = f.vault.request_redeem(&user, &200); + wind_down_now(&f); + f.vault.finalize_wind_down_round(); + f.share.pause(&f.admin); + + f.vault.cancel_redeem(&user, &epoch); + + assert!(f.vault.claim_wind_down(&user) > 0); +} + +/// A freeze holds everything, even an escrow a wind-down would otherwise surrender. +#[test] +fn a_frozen_holders_redemption_stays_escrowed_in_a_wind_down() { + let f = setup(); + let user = f.holder(200); + let epoch = f.vault.request_redeem(&user, &200); + wind_down_now(&f); + f.vault.finalize_wind_down_round(); + f.share.set_address_frozen(&user, &true, &f.compliance); + + refused(f.vault.try_cancel_redeem(&user, &epoch), VaultError::Frozen); + assert_eq!(f.shares(&f.vault.address), 200); + assert!(f.vault.get_redeem_request(&epoch, &user).is_some()); + + f.share.set_address_frozen(&user, &false, &f.compliance); + f.vault.cancel_redeem(&user, &epoch); + assert_eq!(f.vault.claim_wind_down(&user), 400); } #[test] diff --git a/contracts/async-vault/src/wind_down.rs b/contracts/async-vault/src/wind_down.rs index ca2aed1..c390e8e 100644 --- a/contracts/async-vault/src/wind_down.rs +++ b/contracts/async-vault/src/wind_down.rs @@ -149,17 +149,40 @@ pub(crate) fn supply_snapshot(e: &Env) -> i128 { storage::get_instance(e, &DataKey::WindDownSupply).unwrap_or(0) } +fn credited_before_snapshot(e: &Env) -> i128 { + storage::get_instance(e, &DataKey::WindDownCredited).unwrap_or(0) +} + /// Shares that exist or are owed: circulating, escrowed against an unpriced -/// redemption, and owed by a deposit that has been priced but not claimed. +/// redemption, owed by a deposit that has been priced but not claimed, and +/// credited straight to a holder's entitlement. fn snapshot_supply(e: &Env) -> i128 { let share_token = state::get_addr(e, &DataKey::ShareToken); FungibleTokenClient::new(e, &share_token) .total_supply() .saturating_sub(state::pending_burn_shares(e)) .checked_add(state::pending_mint_shares(e)) + .and_then(|supply| supply.checked_add(credited_before_snapshot(e))) .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)) } +/// Shares credited before the first round are counted into its snapshot, having left the supply. +pub(crate) fn credit(e: &Env, holder: &Address, shares: i128) { + let mut p = position(e, holder); + p.entitlement = p + .entitlement + .checked_add(shares) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); + storage::set_persistent(e, &DataKey::WindDownPosition(holder.clone()), &p); + + if info(e).map(|wd| wd.round).unwrap_or(0) == 0 { + let credited = credited_before_snapshot(e) + .checked_add(shares) + .unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge)); + storage::set_instance(e, &DataKey::WindDownCredited, &credited); + } +} + pub(crate) fn finalize_round(e: &Env) -> i128 { let mut wd = info(e).unwrap_or_else(|| panic_with_error!(e, VaultError::WindDownNotActive)); if wd.status != WindDownStatus::Active { diff --git a/scripts/harness/guards.test.ts b/scripts/harness/guards.test.ts index a51550f..8106e15 100644 --- a/scripts/harness/guards.test.ts +++ b/scripts/harness/guards.test.ts @@ -463,8 +463,8 @@ describe("a de-listed holder", () => { ) expectRefused( await vault(c, first).cancel_redeem({ from, epoch_id: epoch }), - shareToken(c, outsider), - "IdentityVerificationFailed", + vault(c, first), + "NotAllowlisted", ) }) From d4358904fc555a9454e16fc6d690c6f207220302 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:44:43 -0300 Subject: [PATCH 2/4] fix(vault): burn unpaid escrow during a wind-down --- contracts/async-vault/src/redeem.rs | 7 +++++-- contracts/async-vault/src/test/wind_down.rs | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/contracts/async-vault/src/redeem.rs b/contracts/async-vault/src/redeem.rs index ff612bb..5ce3c7a 100644 --- a/contracts/async-vault/src/redeem.rs +++ b/contracts/async-vault/src/redeem.rs @@ -85,7 +85,8 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { state::reduce(e, state::pending_burn_shares(e), request.shares), ); - if assets == 0 { + // In a wind-down they are burned below instead: its snapshot left them out. + if assets == 0 && !wind_down::is_active(e) { return_shares(e, caller, epoch_id, request.shares); settlement::on_redeem_claimed(e, epoch_id, 0); return 0; @@ -104,7 +105,9 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { state::set_redeem_request(e, epoch_id, caller, &request); state::set_committed(e, state::reduce(e, state::committed(e), assets)); - TokenClient::new(e, &asset).transfer(&vault, caller, &assets); + if assets > 0 { + TokenClient::new(e, &asset).transfer(&vault, caller, &assets); + } RWATokenClient::new(e, &state::get_addr(e, &DataKey::ShareToken)).burn( &vault, &request.shares, diff --git a/contracts/async-vault/src/test/wind_down.rs b/contracts/async-vault/src/test/wind_down.rs index b79b979..2aef274 100644 --- a/contracts/async-vault/src/test/wind_down.rs +++ b/contracts/async-vault/src/test/wind_down.rs @@ -489,6 +489,26 @@ fn a_frozen_holders_redemption_stays_escrowed_in_a_wind_down() { assert_eq!(f.vault.claim_wind_down(&user), 400); } +/// The snapshot left this escrow out, so its shares must not come back to be surrendered. +#[test] +fn a_redemption_that_pays_nothing_returns_no_shares_in_a_wind_down() { + let f = setup(); + let a = f.holder(200); + let b = f.holder(200); + let epoch = f.vault.request_redeem(&a, &1); + f.fulfill_epoch(WAD_SCALE / 2); + wind_down_now(&f); + assert_eq!(f.vault.finalize_wind_down_round(), 799); + + assert_eq!(f.vault.claim_redeem(&a, &epoch), 0); + let paid = f.vault.claim_wind_down(&a) + f.vault.claim_wind_down(&b); + + assert_eq!(paid, 799); + assert_eq!(f.vault.wind_down_owed(), 0); + assert_eq!(f.shares(&a), 0); + assert_eq!(f.shares(&f.vault.address), 0); +} + #[test] fn the_payouts_never_exceed_what_the_rounds_credited() { let f = setup(); From 338748a997344a12ab3751b56f131a84021a96ed Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:45:17 -0300 Subject: [PATCH 3/4] docs: describe the delisted exit path --- AGENTS.md | 3 ++- docs/COMPLIANCE_MATRIX.md | 30 +++++++++++++++---------- docs/kit-spec.md | 7 ++++-- docs/strata-product-and-architecture.md | 11 ++++----- 4 files changed, 31 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 79d465b..7b1d7c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,8 @@ What the contracts enforce: 2. **A covered claim always pays.** Once priced and covered, a cash claim cannot be blocked by a pause, a stale valuation, or the investor losing their allowlist place. Priced claims are never re-priced and never identity-gated; - a delisted, non-frozen investor leaves through the exit-only cash path. + 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. diff --git a/docs/COMPLIANCE_MATRIX.md b/docs/COMPLIANCE_MATRIX.md index 5730423..a75ce99 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. - - A frozen investor is outside the payable-claims guarantee (design document §8.1). The vault does not check freezes today, so a frozen holder's exit still goes through; the kit does not promise it. + - 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. --- @@ -29,11 +29,11 @@ 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** | ⚠️ Allowed while still allowlisted | The vault asks the share token's identity verifier before any cash moves and refuses an address the token would not let hold shares (`NotAllowlisted`). Freezes are not checked at this step. | +| **`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. | -| **`claim_deposit`** | ✅ Allowed | ❌ **Refused** | ⚠️ Minted if allowlisted | `RWATokenClient::mint` calls `verify_identity` and `can_create`, and does not check freezes. A de-listed receiver is refused with `IdentityVerificationFailed`. A frozen receiver still on the allowlist is minted, and cannot transfer those shares while frozen. | +| **`claim_deposit`** | ✅ Allowed | ↪ **Moved to the exit queue** | ⛔ **Held** | A receiver the share token would refuse 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 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. | -| **`cancel_redeem`** | ✅ Allowed | ❌ **Refused** | ❌ **Refused** | `return_shares` uses standard `FungibleTokenClient::transfer(vault, controller, shares)`, which executes `can_transfer`. Reverts if `controller` is de-listed or frozen. Holder cannot re-acquire shares. Refused with `EnforcedPause` while the share-token transfer pause is active; the request stays pending until the pause is lifted or the epoch prices. | +| **`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. | | **`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,7 +42,7 @@ 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 vault 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 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. - **Mechanism**: 1. A de-listed holder queues an exit via `request_redeem`. The vault escrows the shares with `forced_transfer`. @@ -60,19 +60,23 @@ If an investor deposits into an open epoch but is de-listed (or KYC expires) bef ### 3.3 De-Listing Between Pricing and Claim If an investor deposits while allowlisted but is de-listed after the epoch is priced: - **The cash is no longer escrow.** Pricing moved it out of the cancellable bucket into the reserve, where it backs the shares the epoch promised. The investor's claim is to those shares, counted in `pending_mint_shares`. -- **`claim_deposit` is refused**: the share token calls `verify_identity` on the receiver when it mints, and fails with `IdentityVerificationFailed`. +- **`claim_deposit` moves the shares to the exit queue.** The share token would refuse to mint to her, so the vault mints the shares into its own escrow and adds them to her redemption in the open epoch. She is paid in cash at that epoch's price, like anyone leaving then, through `claim_redeem`. A fall in the price since her deposit was priced is hers, as it would be for any holder who exits. - **`cancel_deposit` is refused** with `AlreadyPriced`: once an epoch has a price, cancellation is closed. -- **In a wind-down** her shares are counted in the supply snapshot, so her part of each round sits in `owed`, and `claim_wind_down` pays only shares the holder holds. Nobody can collect it. -- **Resolution today**: relisting. Once compliance re-allowlists the address, `claim_deposit` mints the shares and the wind-down pays them (`a_holder_delisted_after_pricing_can_only_leave_by_being_relisted`). +- **In a wind-down**, which prices nothing further, the shares join her wind-down entitlement and `claim_wind_down` pays them. +- **In a wind-down, cancelling any escrowed redemption**, whether from her converted deposit or one she requested before the delisting, burns the escrow into the holder's wind-down entitlement (`RedemptionSurrendered`). No shares come back, for any holder. +- **The conversion needs the vault's own allowlist place**, because the vault mints the shares into its own escrow. If compliance removes the vault's address, her conversion waits until the vault is relisted. Nothing is lost. +- **Relisting first** still works: if compliance re-allowlists her before she claims, `claim_deposit` mints the shares to her. +- **A freeze holds her.** If she is also frozen, `claim_deposit` fails with `Frozen` (6071) and the priced deposit stays unclaimed until the freeze is lifted. An address that was never allowlisted cannot reach this state: `request_deposit` refuses it before any cash moves. ### 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` requires transferring shares from the vault back to the investor's account (`return_shares`). -- `ShareToken::transfer` refuses a frozen receiver (`AddressFrozen`) and a receiver off the allowlist (`IdentityVerificationFailed`), so returning the shares is refused. +- 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. --- @@ -100,7 +104,8 @@ The behavior specified in this matrix is verified by on-chain automated test sui * `an_investor_who_was_never_allowlisted_is_refused_before_any_cash_moves`: Verifies a non-allowlisted depositor is refused and nothing moves. * `a_delisted_investor_cannot_request_a_deposit`: Verifies a de-listed depositor is refused at request time. * `relisting_after_pricing_lets_the_claim_through`: Verifies KYC restoration unblocks a priced claim. -* `a_holder_delisted_after_pricing_can_only_leave_by_being_relisted`: Records the open case: no claim, no cancel, the wind-down holds her part. +* `a_delisted_holders_priced_deposit_joins_their_redemption_in_the_open_epoch`, `the_delisted_holder_is_paid_at_the_next_epochs_price`, `in_a_wind_down_the_shares_are_credited_before_the_first_round`, `a_conversion_caught_by_a_wind_down_is_paid_from_the_rounds`, `a_frozen_and_delisted_holders_claim_is_held`: Verify the exit path for a holder delisted after pricing ([`test/delisted.rs`](../contracts/async-vault/src/test/delisted.rs)). +* `a_holder_delisted_after_pricing_is_paid_through_a_wind_down`: The combined case, with the cancel refused as `AlreadyPriced` ([`test/compliance.rs`](../contracts/async-vault/src/test/compliance.rs)). * `a_compliance_module_that_refuses_the_mint_blocks_a_priced_claim`: Records that a compliance module can block a priced claim (a trust assumption). * `a_delisted_holder_can_still_queue_an_exit`: Verifies de-listed holders can queue redemption via forced transfer. * `a_delisted_holder_can_still_claim_their_exit`: Verifies de-listed holders always receive cash payout. @@ -108,6 +113,7 @@ The behavior specified in this matrix is verified by on-chain automated test sui * `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_or_delisted_holder_cannot_transfer_shares`: Verifies secondary market transfers are strictly refused for de-listed and frozen accounts. -* `a_frozen_investor_on_the_allowlist_is_still_minted`: Records that minting does not check freezes. +* `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. diff --git a/docs/kit-spec.md b/docs/kit-spec.md index e93acf3..13c9611 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; only the share token's own transfer pause can refuse it (Invariant 6). + - `cancel_redeem` remains open; outside a wind-down only the share token's own transfer pause can refuse it (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,8 @@ 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`). - - `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` is refused with `PausableError::EnforcedPause` while the share token's transfer pause is active; the request stays pending and the cash claim remains open. + - 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. @@ -96,6 +97,8 @@ The contracts enforce a set of mathematical and state-machine invariants across - **Mathematical Statement**: $$\sum_{i} \text{payout}_i \le \text{round\_assets}$$ - **Testable Property**: In any distribution round with arbitrary share distributions and rounding remainders, the vault remains solvent for the last claimant. Active wind-down is strictly irreversible. +- An escrowed redemption cancelled during the wind-down counts toward the holder's entitlement, and so does a priced deposit a delisted holder claims. Credited before the first round, those shares count in its supply snapshot (`a_credit_before_the_first_round_leaves_every_holder_their_share`). +- During an active wind-down, a priced redemption whose claim pays nothing burns its escrowed shares instead of returning them, because the snapshot left them out (`a_redemption_that_pays_nothing_returns_no_shares_in_a_wind_down`). - Each payout floors per holder. Once every holder whose shares were all surrendered has claimed, `wind_down_owed` keeps less than one unit per such holder, plus whatever value the snapshot counted in shares that never actually minted (for example `pending_mint_shares` from an epoch that promised more shares than it later minted). That remainder is not released: a later round lets each holder's floor catch up and needs it (`a_later_round_needs_the_units_an_earlier_round_left_in_owed`, `after_every_holder_claims_owed_keeps_at_most_one_unit_per_holder`, `owed_stays_below_the_holder_count_once_every_share_is_surrendered`). --- diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index 3288965..72bfddc 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -310,15 +310,15 @@ batch boundary, though not the price it receives. #### Subscription -- Request: moves the deposit asset into escrow; at most one active request per controller. The vault first asks the share token's identity verifier and refuses an address that could not receive the shares, before any cash moves; the token checks again when it mints at claim time. +- 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. -- Share claim: verifies the receiver and delivers the shares via `mint`. If verification fails, the position remains in pending mint and exits through administrative resolution or once allowlisted. No nominal refund exists after pricing. +- Share claim: verifies the receiver and delivers the shares via `mint`. A receiver the token would refuse 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. -- Cancellation: atomic, and returns the escrowed shares under the same rules as subscription cancellation; stays open during an active wind-down. +- 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. @@ -448,8 +448,9 @@ interface today. holder who never surrenders their shares is never paid, because no entrypoint may iterate over holders; a refund owed to a deposit priced at zero shares is not tracked as a cash liability, so a round can credit the assets it needed; - and symmetrically, a redemption priced at zero assets before the snapshot - returns its shares outside the wind-down distribution. + and symmetrically, a redemption priced at zero assets pays nothing, and once + the wind-down is active its claim burns the escrowed shares instead of + returning them. - Funding a vault that is winding down cannot be undone. What arrives first covers priced exits and refundable escrow; the surplus is taken by the next round and credited to holders, and no entrypoint returns it. Treasury sends From 7cdc0ccafdf83c118dc9030a3fd185f96d5040b1 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:25:59 -0300 Subject: [PATCH 4/4] fix(vault): address delisted exit review round 1 --- contracts/async-vault/src/deposit.rs | 44 +++++++++++++------- contracts/async-vault/src/error.rs | 7 ++-- contracts/async-vault/src/lib.rs | 3 ++ contracts/async-vault/src/redeem.rs | 5 +-- contracts/async-vault/src/test/compliance.rs | 21 ++++++++++ contracts/async-vault/src/test/delisted.rs | 18 ++++++++ docs/COMPLIANCE_MATRIX.md | 2 +- docs/strata-product-and-architecture.md | 2 +- 8 files changed, 80 insertions(+), 22 deletions(-) diff --git a/contracts/async-vault/src/deposit.rs b/contracts/async-vault/src/deposit.rs index 67f1bda..7104cdb 100644 --- a/contracts/async-vault/src/deposit.rs +++ b/contracts/async-vault/src/deposit.rs @@ -5,7 +5,7 @@ use crate::epoch; use crate::error::VaultError; use crate::event::{DepositCancelled, DepositClaimed, DepositConvertedToExit, DepositRequested}; 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::redeem; use crate::settlement; @@ -104,8 +104,11 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { let standing = lock_policy::standing(e, caller); let decision = lock_policy::decide(Exit::ClaimDeposit, standing, wind_down::is_active(e)); - if matches!(decision, Decision::Refuse | Decision::Hold) { - panic_with_error!(e, lock_policy::refused_with(standing)); + match decision { + Decision::Proceed | Decision::ConvertToExit | Decision::Surrender => {} + Decision::Refuse | Decision::Hold => { + panic_with_error!(e, lock_policy::refused_with(standing)) + } } let shares = Pricing::deposit_shares(e, request.amount, epoch.share_price) @@ -128,7 +131,7 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { state::set_pending_mint_shares(e, state::reduce(e, state::pending_mint_shares(e), shares)); if decision != Decision::Proceed { - convert_to_exit(e, caller, epoch_id, shares, decision); + convert_to_exit(e, caller, epoch_id, shares, standing, decision); return 0; } @@ -147,16 +150,29 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 { shares } -/// The token refuses this holder shares, so they exit in cash: next price, or the wind-down payout. -fn convert_to_exit(e: &Env, holder: &Address, epoch_id: u64, shares: i128, decision: Decision) { - let into_epoch = if decision == Decision::Surrender { - wind_down::credit(e, holder, shares); - 0 - } else { - let vault = e.current_contract_address(); - let share_token = state::get_addr(e, &DataKey::ShareToken); - RWATokenClient::new(e, &share_token).mint(&vault, &shares, &vault); - redeem::queue_escrowed(e, holder, shares) +/// The holder is off the allowlist, so the shares exit in cash: next price, or the wind-down. +fn convert_to_exit( + e: &Env, + holder: &Address, + epoch_id: u64, + shares: i128, + standing: Standing, + decision: Decision, +) { + let into_epoch = match decision { + Decision::ConvertToExit => { + let vault = e.current_contract_address(); + let share_token = state::get_addr(e, &DataKey::ShareToken); + RWATokenClient::new(e, &share_token).mint(&vault, &shares, &vault); + redeem::queue_escrowed(e, holder, shares) + } + Decision::Surrender => { + wind_down::credit(e, holder, shares); + 0 + } + Decision::Proceed | Decision::Refuse | Decision::Hold => { + panic_with_error!(e, lock_policy::refused_with(standing)) + } }; DepositConvertedToExit { diff --git a/contracts/async-vault/src/error.rs b/contracts/async-vault/src/error.rs index 47ab3c2..efd6413 100644 --- a/contracts/async-vault/src/error.rs +++ b/contracts/async-vault/src/error.rs @@ -115,12 +115,13 @@ pub enum VaultError { /// As many changes as the timelock holds already wait. Apply or cancel one /// first. TooManyPendingChanges = 6063, - /// The depositor fails the share token's identity check, so the vault - /// could never deliver the shares. Refused before any cash moves. + /// 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. NotAllowlisted = 6068, /// 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: nothing moves for them until the freeze is lifted. + /// The holder is frozen: a deposit request is refused, and a share claim or + /// a redemption cancel waits until the freeze is lifted. Frozen = 6071, } diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 5b5b3f2..657677d 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -344,6 +344,9 @@ impl AsyncVault { deposit::request(e, &from, amount) } + /// Returns the shares minted to the caller. Zero means none were: a deposit + /// too small for one share was refunded, or the caller is off the allowlist + /// and the shares joined an exit instead (`DepositConvertedToExit`). pub fn claim_deposit(e: &Env, caller: Address, epoch_id: u64) -> i128 { deposit::claim(e, &caller, epoch_id) } diff --git a/contracts/async-vault/src/redeem.rs b/contracts/async-vault/src/redeem.rs index 5ce3c7a..21d0dbd 100644 --- a/contracts/async-vault/src/redeem.rs +++ b/contracts/async-vault/src/redeem.rs @@ -158,9 +158,8 @@ pub(crate) fn queue_escrowed(e: &Env, controller: &Address, shares: i128) -> u64 } /// Moves escrowed shares back to the controller and clears the request. The -/// checked transfer is deliberate: the share token decides whether this holder -/// may receive a regulated share, so a frozen or de-listed controller is refused -/// here and exits through the cash claim instead. +/// 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. 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 7c42c25..4992fd1 100644 --- a/contracts/async-vault/src/test/compliance.rs +++ b/contracts/async-vault/src/test/compliance.rs @@ -243,6 +243,27 @@ fn a_frozen_investor_is_not_minted_until_unfrozen() { f.share.set_address_frozen(&investor, &false, &f.compliance); assert_eq!(f.vault.claim_deposit(&investor, &1), 200); + assert_eq!(f.shares(&investor), 200); +} + +#[test] +fn a_frozen_investors_zero_share_refund_is_held() { + let f = setup(); + let investor = f.investor(10); + let epoch = f.vault.request_deposit(&investor, &1); + f.fulfill_epoch(wad(2)); + f.share.set_address_frozen(&investor, &true, &f.compliance); + + refused( + f.vault.try_claim_deposit(&investor, &epoch), + VaultError::Frozen, + ); + assert_eq!(f.balance(&investor), 9); + assert!(f.vault.get_deposit_request(&epoch, &investor).is_some()); + + f.share.set_address_frozen(&investor, &false, &f.compliance); + assert_eq!(f.vault.claim_deposit(&investor, &epoch), 0); + assert_eq!(f.balance(&investor), 10); } #[test] diff --git a/contracts/async-vault/src/test/delisted.rs b/contracts/async-vault/src/test/delisted.rs index 8209ed9..0814b8b 100644 --- a/contracts/async-vault/src/test/delisted.rs +++ b/contracts/async-vault/src/test/delisted.rs @@ -111,6 +111,24 @@ fn in_a_wind_down_the_shares_are_credited_before_the_first_round() { f.vault.activate_wind_down(); assert_eq!(f.vault.claim_deposit(&investor, &epoch), 0); + assert_eq!( + f.e.events().all().filter_by_contract(&f.vault.address), + [ + DepositConvertedToExit { + controller: investor.clone(), + epoch, + into_epoch: 0, + shares: 200, + } + .to_xdr(&f.e, &f.vault.address), + EpochReconciled { + epoch, + share_dust: 0, + cash_dust: 0, + } + .to_xdr(&f.e, &f.vault.address), + ] + ); assert_eq!(f.vault.finalize_wind_down_round(), 400); assert_eq!(f.vault.claim_wind_down(&investor), 400); diff --git a/docs/COMPLIANCE_MATRIX.md b/docs/COMPLIANCE_MATRIX.md index a75ce99..67b38cd 100644 --- a/docs/COMPLIANCE_MATRIX.md +++ b/docs/COMPLIANCE_MATRIX.md @@ -31,7 +31,7 @@ Every investor account in the vault ecosystem exists in one of three primary com |---|---|---|---|---| | **`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. | -| **`claim_deposit`** | ✅ Allowed | ↪ **Moved to the exit queue** | ⛔ **Held** | A receiver the share token would refuse 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 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. | +| **`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. | | **`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. | diff --git a/docs/strata-product-and-architecture.md b/docs/strata-product-and-architecture.md index 72bfddc..8967577 100644 --- a/docs/strata-product-and-architecture.md +++ b/docs/strata-product-and-architecture.md @@ -313,7 +313,7 @@ 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. -- Share claim: verifies the receiver and delivers the shares via `mint`. A receiver the token would refuse gets no shares: they join the holder's redemption in the open epoch and are paid in cash at that epoch's price; in a wind-down they join the holder's wind-down entitlement. A frozen receiver is held until the freeze is lifted. No nominal refund exists after pricing. +- Share claim: verifies the receiver and delivers the shares via `mint`. A receiver off the allowlist gets no shares: they join the holder's redemption in the open epoch and are paid in cash at that epoch's price; in a wind-down they join the holder's wind-down entitlement. A frozen receiver is held until the freeze is lifted. No nominal refund exists after pricing. #### Redemption