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
21 changes: 14 additions & 7 deletions contracts/async-vault/src/deposit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use crate::event::{DepositCancelled, DepositClaimed, DepositRequested};
use crate::keys::DataKey;
use crate::lock_policy::{self, Decision, Exit};
use crate::pricing::{Pricing, PricingScheme};
use crate::settlement;
use crate::state::{self, DepositRequest, EpochStatus};
use crate::treasury;
use crate::wind_down;
Expand Down Expand Up @@ -66,6 +67,7 @@ pub(crate) fn request(e: &Env, from: &Address, amount: i128) -> u64 {
);
state::set_epoch(e, epoch_id, &epoch);
state::set_cancellable_escrow(e, state::cancellable_escrow(e) + amount);
settlement::on_deposit_requested(e, epoch_id);

let asset = state::get_addr(e, &DataKey::Asset);
TokenClient::new(e, &asset).transfer(from, e.current_contract_address(), &amount);
Expand Down Expand Up @@ -107,12 +109,14 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 {
if treasury::free_reserve(e) < request.amount {
panic_with_error!(e, VaultError::ClaimNotCovered);
}
return refund_deposit(e, caller, epoch_id, request.amount);
refund_deposit(e, caller, epoch_id, request.amount);
settlement::on_deposit_claimed(e, epoch_id, 0);
return 0;
}

request.claimed = true;
state::set_deposit_request(e, epoch_id, caller, &request);
state::set_pending_mint_shares(e, state::pending_mint_shares(e).saturating_sub(shares));
state::set_pending_mint_shares(e, state::reduce(e, state::pending_mint_shares(e), shares));

let share_token = state::get_addr(e, &DataKey::ShareToken);
RWATokenClient::new(e, &share_token).mint(caller, &shares, &e.current_contract_address());
Expand All @@ -124,16 +128,17 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 {
shares,
}
.publish(e);
settlement::on_deposit_claimed(e, epoch_id, shares);

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) -> i128 {
fn refund_deposit(e: &Env, controller: &Address, epoch_id: u64, amount: i128) {
let mut epoch = state::get_epoch(e, epoch_id)
.unwrap_or_else(|| panic_with_error!(e, VaultError::EpochNotFound));
epoch.total_deposited -= amount;
epoch.total_deposited = state::reduce(e, epoch.total_deposited, amount);
state::set_epoch(e, epoch_id, &epoch);
state::remove_deposit_request(e, epoch_id, controller);

Expand All @@ -146,8 +151,6 @@ fn refund_deposit(e: &Env, controller: &Address, epoch_id: u64, amount: i128) ->
amount,
}
.publish(e);

0
}

pub(crate) fn cancel(e: &Env, from: &Address, epoch_id: u64) -> i128 {
Expand All @@ -171,7 +174,11 @@ 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));

state::set_cancellable_escrow(e, state::cancellable_escrow(e) - request.amount);
state::set_cancellable_escrow(
e,
state::reduce(e, state::cancellable_escrow(e), request.amount),
);
settlement::on_deposit_cancelled(e, epoch_id);
refund_deposit(e, from, epoch_id, request.amount);
request.amount
}
10 changes: 8 additions & 2 deletions contracts/async-vault/src/epoch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use crate::error::VaultError;
use crate::event::{EpochClosed, EpochFulfilled};
use crate::keys::DataKey;
use crate::pricing::{Pricing, PricingScheme};
use crate::settlement;
use crate::state::{self, EpochInfo, EpochStatus};
use crate::timing::{FulfilmentTiming, StandardTiming};
use crate::wind_down;
Expand Down Expand Up @@ -108,8 +109,9 @@ pub(crate) fn fulfill(e: &Env, epoch_id: u64) -> i128 {
.unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge));
state::set_committed(e, committed);

let mut shares_owed = 0;
if epoch.total_deposited > 0 {
let shares_owed = Pricing::deposit_shares(e, epoch.total_deposited, share_price)
shares_owed = Pricing::deposit_shares(e, epoch.total_deposited, share_price)
.unwrap_or_else(|err| panic_with_error!(e, err));
let updated_pending_mint = state::pending_mint_shares(e)
.checked_add(shares_owed)
Expand All @@ -124,7 +126,10 @@ pub(crate) fn fulfill(e: &Env, epoch_id: u64) -> i128 {
state::set_pending_burn_shares(e, updated_pending_burn);
}

state::set_cancellable_escrow(e, state::cancellable_escrow(e) - epoch.total_deposited);
state::set_cancellable_escrow(
e,
state::reduce(e, state::cancellable_escrow(e), epoch.total_deposited),
);

epoch.status = EpochStatus::Fulfilled;
epoch.share_price = share_price;
Expand All @@ -136,6 +141,7 @@ pub(crate) fn fulfill(e: &Env, epoch_id: u64) -> i128 {
total_deposited: epoch.total_deposited,
}
.publish(e);
settlement::on_priced(e, epoch_id, shares_owed, owed);

share_price
}
3 changes: 3 additions & 0 deletions contracts/async-vault/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,7 @@ pub enum VaultError {
/// The depositor fails the share token's identity check, so the vault
/// could never deliver the shares. Refused before any cash moves.
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,
}
8 changes: 8 additions & 0 deletions contracts/async-vault/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,14 @@ pub struct WindDownClaimed {
pub assets: i128,
}

#[contractevent]
pub struct EpochReconciled {
#[topic]
pub epoch: u64,
pub share_dust: i128,
pub cash_dust: i128,
}

#[contractevent]
pub struct DepositCapUpdated {
pub old_cap: Option<i128>,
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 @@ -24,4 +24,5 @@ pub(crate) enum DataKey {
WindDownOwed,
WindDownPosition(Address),
DepositCap,
Settlement(u64),
}
13 changes: 10 additions & 3 deletions contracts/async-vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ mod lock_policy;
mod pricing;
mod redeem;
mod roles;
mod settlement;
mod state;
mod timing;
mod treasury;
Expand All @@ -31,13 +32,14 @@ use state::FIRST_EPOCH;
pub use error::VaultError;
pub use event::{
ChangeApplied, ChangeCancelled, ChangeProposed, CustodianSet, Deployed, DepositCapUpdated,
DepositClaimed, DepositRequested, EpochClosed, EpochFulfilled, Funded, NoticeSet,
RedeemClaimed, RedeemRequested, WindDownActivated, WindDownClaimed, WindDownDelaySet,
WindDownProposalCancelled, WindDownProposed, WindDownRoundFinalized,
DepositClaimed, DepositRequested, EpochClosed, EpochFulfilled, EpochReconciled, Funded,
NoticeSet, RedeemClaimed, RedeemRequested, WindDownActivated, WindDownClaimed,
WindDownDelaySet, WindDownProposalCancelled, WindDownProposed, WindDownRoundFinalized,
};
pub use governance::{VaultChange, VaultPendingChange};
pub use pricing::{DirectUnitPricing, PricingScheme};
pub use roles::VaultRoles;
pub use settlement::EpochSettlement;
pub use state::{DepositRequest, EpochInfo, EpochStatus, RedeemRequest};
pub use upgrade::{MAX_UPGRADE_DELAY, MIN_UPGRADE_DELAY};
pub use wind_down::{WindDownInfo, WindDownPosition, WindDownStatus, MAX_WIND_DOWN_DELAY};
Expand Down Expand Up @@ -323,6 +325,11 @@ impl AsyncVault {
state::get_epoch(e, epoch_id)
}

/// What pricing promised the epoch and what has been paid against it.
pub fn get_epoch_settlement(e: &Env, epoch_id: u64) -> Option<EpochSettlement> {
settlement::get(e, epoch_id)
}

pub fn get_deposit_request(
e: &Env,
epoch_id: u64,
Expand Down
11 changes: 8 additions & 3 deletions contracts/async-vault/src/redeem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use crate::error::VaultError;
use crate::event::{RedeemCancelled, RedeemClaimed, RedeemRequested};
use crate::keys::DataKey;
use crate::pricing::{Pricing, PricingScheme};
use crate::settlement;
use crate::state::{self, EpochStatus, RedeemRequest};
use crate::treasury;
use crate::wind_down;
Expand Down Expand Up @@ -42,6 +43,7 @@ pub(crate) fn request(e: &Env, from: &Address, shares: i128) -> u64 {
},
);
state::set_epoch(e, epoch_id, &epoch);
settlement::on_redeem_requested(e, epoch_id);

let vault = e.current_contract_address();
let share_token = state::get_addr(e, &DataKey::ShareToken);
Expand Down Expand Up @@ -79,11 +81,12 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 {

state::set_pending_burn_shares(
e,
state::pending_burn_shares(e).saturating_sub(request.shares),
state::reduce(e, state::pending_burn_shares(e), request.shares),
);

if assets == 0 {
return_shares(e, caller, epoch_id, request.shares);
settlement::on_redeem_claimed(e, epoch_id, 0);
return 0;
}

Expand All @@ -98,7 +101,7 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 {

request.claimed = true;
state::set_redeem_request(e, epoch_id, caller, &request);
state::set_committed(e, state::committed(e).saturating_sub(assets));
state::set_committed(e, state::reduce(e, state::committed(e), assets));

TokenClient::new(e, &asset).transfer(&vault, caller, &assets);
RWATokenClient::new(e, &state::get_addr(e, &DataKey::ShareToken)).burn(
Expand All @@ -114,6 +117,7 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 {
assets,
}
.publish(e);
settlement::on_redeem_claimed(e, epoch_id, assets);

assets
}
Expand All @@ -125,7 +129,7 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 {
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));
epoch.total_shares_redeeming -= shares;
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);

Expand Down Expand Up @@ -163,5 +167,6 @@ pub(crate) fn cancel(e: &Env, from: &Address, epoch_id: u64) -> i128 {
.unwrap_or_else(|| panic_with_error!(e, VaultError::RequestNotFound));

return_shares(e, from, epoch_id, request.shares);
settlement::on_redeem_cancelled(e, epoch_id);
request.shares
}
115 changes: 115 additions & 0 deletions contracts/async-vault/src/settlement.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
use soroban_sdk::{contracttype, panic_with_error, Env};

use crate::error::VaultError;
use crate::event::EpochReconciled;
use crate::keys::DataKey;
use crate::state::{self, EpochStatus};

/// What pricing promised an epoch's requests and what has been paid against
/// it. Every claim floors, so the two drift apart by a few units; once the
/// last request settles the remainder leaves `pending_mint_shares` and
/// `committed`, and the cash stays in the reserve as fund money.
#[contracttype]
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct EpochSettlement {
pub deposits_open: u32,
pub redeems_open: u32,
pub shares_promised: i128,
pub shares_settled: i128,
pub assets_promised: i128,
pub assets_settled: i128,
pub reconciled: bool,
}

pub(crate) fn get(e: &Env, epoch: u64) -> Option<EpochSettlement> {
storage::get_persistent(e, &DataKey::Settlement(epoch))
}

fn load(e: &Env, epoch: u64) -> EpochSettlement {
get(e, epoch).unwrap_or_default()
}

fn save(e: &Env, epoch: u64, s: &EpochSettlement) {
storage::set_persistent(e, &DataKey::Settlement(epoch), s);
}

fn close_one(e: &Env, open: u32) -> u32 {
open.checked_sub(1)
.unwrap_or_else(|| panic_with_error!(e, VaultError::AccountingMismatch))
}

pub(crate) fn on_deposit_requested(e: &Env, epoch: u64) {
let mut s = load(e, epoch);
s.deposits_open += 1;
save(e, epoch, &s);
}

pub(crate) fn on_deposit_cancelled(e: &Env, epoch: u64) {
let mut s = load(e, epoch);
s.deposits_open = close_one(e, s.deposits_open);
save(e, epoch, &s);
}

pub(crate) fn on_redeem_requested(e: &Env, epoch: u64) {
let mut s = load(e, epoch);
s.redeems_open += 1;
save(e, epoch, &s);
}

pub(crate) fn on_redeem_cancelled(e: &Env, epoch: u64) {
let mut s = load(e, epoch);
s.redeems_open = close_one(e, s.redeems_open);
save(e, epoch, &s);
}

/// Called once the epoch is stored as fulfilled.
pub(crate) fn on_priced(e: &Env, epoch: u64, shares_promised: i128, assets_promised: i128) {
let mut s = load(e, epoch);
s.shares_promised = shares_promised;
s.assets_promised = assets_promised;
reconcile_if_settled(e, epoch, &mut s);
save(e, epoch, &s);
}

/// `shares` is zero for a claim that floored to nothing and was refunded.
pub(crate) fn on_deposit_claimed(e: &Env, epoch: u64, shares: i128) {
let mut s = load(e, epoch);
s.deposits_open = close_one(e, s.deposits_open);
s.shares_settled += shares;
reconcile_if_settled(e, epoch, &mut s);
save(e, epoch, &s);
}

/// `assets` is zero for a claim that paid nothing and returned the shares.
pub(crate) fn on_redeem_claimed(e: &Env, epoch: u64, assets: i128) {
let mut s = load(e, epoch);
s.redeems_open = close_one(e, s.redeems_open);
s.assets_settled += assets;
reconcile_if_settled(e, epoch, &mut s);
save(e, epoch, &s);
}

fn reconcile_if_settled(e: &Env, epoch: u64, s: &mut EpochSettlement) {
if s.reconciled || s.deposits_open > 0 || s.redeems_open > 0 {
return;
}
let priced = state::get_epoch(e, epoch).is_some_and(|ep| ep.status == EpochStatus::Fulfilled);
if !priced {
return;
}
let share_dust = state::reduce(e, s.shares_promised, s.shares_settled);
let cash_dust = state::reduce(e, s.assets_promised, s.assets_settled);
state::set_pending_mint_shares(
e,
state::reduce(e, state::pending_mint_shares(e), share_dust),
);
state::set_committed(e, state::reduce(e, state::committed(e), cash_dust));
s.reconciled = true;

EpochReconciled {
epoch,
share_dust,
cash_dust,
}
.publish(e);
}
9 changes: 9 additions & 0 deletions contracts/async-vault/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ use crate::keys::DataKey;

pub(crate) const FIRST_EPOCH: u64 = 1;

/// Takes `amount` off a running total that can never be negative. A result
/// below zero means the books disagree, so it fails loudly.
pub(crate) fn reduce(e: &Env, total: i128, amount: i128) -> i128 {
match total.checked_sub(amount) {
Some(left) if left >= 0 => left,
_ => panic_with_error!(e, VaultError::AccountingMismatch),
}
}

#[contracttype]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum EpochStatus {
Expand Down
2 changes: 1 addition & 1 deletion contracts/async-vault/src/test/compliance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ fn a_holder_delisted_after_pricing_can_only_leave_by_being_relisted() {
}

#[contract]
struct RefusesEveryMint;
pub(super) struct RefusesEveryMint;

#[contractimpl]
impl RefusesEveryMint {
Expand Down
Loading
Loading