Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions contracts/async-vault/src/deposit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
5 changes: 3 additions & 2 deletions contracts/async-vault/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
5 changes: 3 additions & 2 deletions contracts/async-vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
29 changes: 28 additions & 1 deletion contracts/async-vault/src/lock_policy.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -33,6 +36,10 @@ pub(crate) enum Exit {
RequestDeposit,
ClaimDeposit,
CancelRedeem,
CancelDeposit,
RequestRedeem,
ClaimRedeem,
ClaimWindDown,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
Expand Down Expand Up @@ -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,
}
}

Expand All @@ -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)
}
31 changes: 28 additions & 3 deletions contracts/async-vault/src/redeem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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));

Expand Down Expand Up @@ -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));
Expand Down
81 changes: 64 additions & 17 deletions contracts/async-vault/src/test/compliance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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());
}
119 changes: 119 additions & 0 deletions contracts/async-vault/src/test/lock_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down Expand Up @@ -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
);
}
6 changes: 5 additions & 1 deletion contracts/async-vault/src/test/redeem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Loading
Loading