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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
64 changes: 58 additions & 6 deletions contracts/async-vault/src/deposit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::lock_policy::{self, Decision, Exit, Standing};
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));
}
}

Expand All @@ -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);
Expand Down Expand Up @@ -99,6 +102,15 @@ 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));
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)
.unwrap_or_else(|err| panic_with_error!(e, err));

Expand All @@ -118,6 +130,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, standing, decision);
return 0;
}

let share_token = state::get_addr(e, &DataKey::ShareToken);
RWATokenClient::new(e, &share_token).mint(caller, &shares, &e.current_contract_address());

Expand All @@ -133,6 +150,41 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 {
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 {
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) {
Expand Down
7 changes: 5 additions & 2 deletions contracts/async-vault/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +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: a deposit request is refused, and a share claim or
/// a redemption cancel waits until the freeze is lifted.
Frozen = 6071,
}
19 changes: 19 additions & 0 deletions contracts/async-vault/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions contracts/async-vault/src/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub(crate) enum DataKey {
WindDownAcc,
WindDownOwed,
WindDownPosition(Address),
WindDownCredited,
DepositCap,
Settlement(u64),
}
15 changes: 10 additions & 5 deletions contracts/async-vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -343,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)
}
Expand All @@ -352,8 +356,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)
}
Expand Down
47 changes: 39 additions & 8 deletions contracts/async-vault/src/lock_policy.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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,
Expand All @@ -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,
}
}
Loading
Loading