Skip to content
Merged
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
6 changes: 3 additions & 3 deletions contracts/async-vault/src/deposit.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
use bindings::ShareClient;
use soroban_sdk::{panic_with_error, token::TokenClient, Address, Env};
use stellar_contract_utils::math::{i128_fixed_point::checked_mul_div_floor, wad::WAD_SCALE};

use crate::error::VaultError;
use crate::event::{DepositCancelled, DepositClaimed, DepositRequested};
use crate::keys::DataKey;
use crate::pricing::{Pricing, PricingScheme};
use crate::state::{self, DepositRequest, EpochStatus};
use crate::treasury;
use crate::wind_down;
Expand Down Expand Up @@ -73,8 +73,8 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 {
panic_with_error!(e, VaultError::AlreadyClaimed);
}

let shares = checked_mul_div_floor(e, &request.amount, &WAD_SCALE, &epoch.share_price)
.unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge));
let shares = Pricing::deposit_shares(e, request.amount, epoch.share_price)
.unwrap_or_else(|err| panic_with_error!(e, err));

if shares == 0 {
// Pricing already released this epoch's escrow into the reserve, so a
Expand Down
18 changes: 7 additions & 11 deletions contracts/async-vault/src/epoch.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
use bindings::{OracleFeedClient, OracleState};
use soroban_sdk::{panic_with_error, Env};
use stellar_contract_utils::math::{i128_fixed_point::checked_mul_div_floor, wad::WAD_SCALE};

use crate::error::VaultError;
use crate::event::{EpochClosed, EpochFulfilled};
use crate::keys::DataKey;
use crate::pricing::{Pricing, PricingScheme};
use crate::state::{self, EpochInfo, EpochStatus};
use crate::timing::{FulfilmentTiming, StandardTiming};
use crate::wind_down;
Expand Down Expand Up @@ -98,23 +98,19 @@ pub(crate) fn fulfill(e: &Env, epoch_id: u64) -> i128 {
panic_with_error!(e, refusal);
}

let feed = OracleFeedClient::new(e, &state::get_addr(e, &DataKey::Oracle));
let share_price = feed.nav_per_share();
if share_price <= 0 {
panic_with_error!(e, VaultError::InvalidSharePrice);
}
let share_price =
Pricing::resolve_share_price(e).unwrap_or_else(|err| panic_with_error!(e, err));

let owed = checked_mul_div_floor(e, &epoch.total_shares_redeeming, &share_price, &WAD_SCALE)
.unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge));
let owed = Pricing::redeem_assets(e, epoch.total_shares_redeeming, share_price)
.unwrap_or_else(|err| panic_with_error!(e, err));
let committed = state::committed(e)
.checked_add(owed)
.unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge));
state::set_committed(e, committed);

if epoch.total_deposited > 0 {
let shares_owed =
checked_mul_div_floor(e, &epoch.total_deposited, &WAD_SCALE, &share_price)
.unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge));
let 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)
.unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge));
Expand Down
2 changes: 2 additions & 0 deletions contracts/async-vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod epoch;
mod error;
mod event;
mod keys;
mod pricing;
mod redeem;
mod roles;
mod state;
Expand All @@ -31,6 +32,7 @@ pub use event::{
UpgradeDelaySet, UpgradeProposed, Upgraded, WindDownActivated, WindDownClaimed,
WindDownDelaySet, WindDownProposalCancelled, WindDownProposed, WindDownRoundFinalized,
};
pub use pricing::{DirectUnitPricing, PricingScheme};
pub use roles::VaultRoles;
pub use state::{DepositRequest, EpochInfo, EpochStatus, RedeemRequest};
pub use upgrade::{UpgradeAction, UpgradeProposal, MAX_UPGRADE_DELAY, MIN_UPGRADE_DELAY};
Expand Down
53 changes: 53 additions & 0 deletions contracts/async-vault/src/pricing.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
use bindings::OracleFeedClient;
use soroban_sdk::Env;
use stellar_contract_utils::math::{i128_fixed_point::checked_mul_div_floor, wad::WAD_SCALE};

use crate::error::VaultError;
use crate::keys::DataKey;
use crate::state;

/// Defines the pricing interface for epoch share price resolution and conversions.
/// A deployment or vault variant selects or implements a scheme conforming to this trait.
pub trait PricingScheme {
/// Resolves the share price for an epoch being fulfilled.
/// Returns the share price in WAD scale (18 decimals), or a VaultError if invalid.
fn resolve_share_price(e: &Env) -> Result<i128, VaultError>;

/// Converts asset amount to share amount at `share_price`, rounding down
/// in the vault's favour (minting fewer shares).
fn deposit_shares(e: &Env, assets: i128, share_price: i128) -> Result<i128, VaultError> {
if share_price <= 0 {
return Err(VaultError::InvalidSharePrice);
}
checked_mul_div_floor(e, &assets, &WAD_SCALE, &share_price)
.ok_or(VaultError::AmountTooLarge)
}

/// Converts share amount to asset amount at `share_price`, rounding down
/// in the vault's favour (paying out fewer assets).
fn redeem_assets(e: &Env, shares: i128, share_price: i128) -> Result<i128, VaultError> {
if share_price <= 0 {
return Err(VaultError::InvalidSharePrice);
}
checked_mul_div_floor(e, &shares, &share_price, &WAD_SCALE)
.ok_or(VaultError::AmountTooLarge)
}
}

/// The standard default pricing scheme: receives a direct unit share price from
/// off-chain fund accounting attested via the oracle feed.
pub struct DirectUnitPricing;

/// The pricing scheme this vault resolves and converts on.
pub(crate) type Pricing = DirectUnitPricing;

impl PricingScheme for DirectUnitPricing {
fn resolve_share_price(e: &Env) -> Result<i128, VaultError> {
let feed = OracleFeedClient::new(e, &state::get_addr(e, &DataKey::Oracle));
let share_price = feed.nav_per_share();
if share_price <= 0 {
return Err(VaultError::InvalidSharePrice);
}
Ok(share_price)
}
}
6 changes: 3 additions & 3 deletions contracts/async-vault/src/redeem.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
use bindings::ShareClient;
use soroban_sdk::{panic_with_error, token::TokenClient, Address, Env};
use stellar_contract_utils::math::{i128_fixed_point::checked_mul_div_floor, wad::WAD_SCALE};

use crate::error::VaultError;
use crate::event::{RedeemCancelled, RedeemClaimed, RedeemRequested};
use crate::keys::DataKey;
use crate::pricing::{Pricing, PricingScheme};
use crate::state::{self, EpochStatus, RedeemRequest};
use crate::wind_down;

Expand Down Expand Up @@ -72,8 +72,8 @@ pub(crate) fn claim(e: &Env, caller: &Address, epoch_id: u64) -> i128 {
panic_with_error!(e, VaultError::AlreadyClaimed);
}

let assets = checked_mul_div_floor(e, &request.shares, &epoch.share_price, &WAD_SCALE)
.unwrap_or_else(|| panic_with_error!(e, VaultError::AmountTooLarge));
let assets = Pricing::redeem_assets(e, request.shares, epoch.share_price)
.unwrap_or_else(|err| panic_with_error!(e, err));

state::set_pending_burn_shares(
e,
Expand Down
6 changes: 4 additions & 2 deletions contracts/async-vault/src/test/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ mod epochs;
mod multi_epoch;
mod notice;
mod oracle_pricing;
mod pricing;
mod redeem;
mod supply;
mod timing;
Expand All @@ -32,8 +33,9 @@ pub(crate) use share_token::{ShareToken, ShareTokenClient};
pub(crate) use stellar_contract_utils::math::wad::WAD_SCALE;

pub(crate) use crate::{
AsyncVault, AsyncVaultClient, EpochStatus, UpgradeAction, VaultError, VaultRoles,
WindDownStatus, MAX_NOTICE_SECS, MAX_UPGRADE_DELAY, MAX_WIND_DOWN_DELAY, MIN_UPGRADE_DELAY,
AsyncVault, AsyncVaultClient, DirectUnitPricing, EpochStatus, PricingScheme, UpgradeAction,
VaultError, VaultRoles, WindDownStatus, MAX_NOTICE_SECS, MAX_UPGRADE_DELAY,
MAX_WIND_DOWN_DELAY, MIN_UPGRADE_DELAY,
};

fn wad(whole: i128) -> i128 {
Expand Down
73 changes: 73 additions & 0 deletions contracts/async-vault/src/test/pricing.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
use super::*;

#[test]
fn direct_unit_pricing_reads_oracle_nav() {
let f = setup();
f.attest(wad(2));

let price = f.e.as_contract(&f.vault.address, || {
DirectUnitPricing::resolve_share_price(&f.e).unwrap()
});
assert_eq!(price, wad(2));
}

#[test]
fn direct_unit_pricing_rejects_zero_or_negative_price() {
let env = Env::default();
// Non-positive share prices are refused by conversion methods
assert_eq!(
DirectUnitPricing::deposit_shares(&env, 100, 0),
Err(VaultError::InvalidSharePrice)
);
assert_eq!(
DirectUnitPricing::deposit_shares(&env, 100, -1),
Err(VaultError::InvalidSharePrice)
);
assert_eq!(
DirectUnitPricing::redeem_assets(&env, 100, 0),
Err(VaultError::InvalidSharePrice)
);
assert_eq!(
DirectUnitPricing::redeem_assets(&env, 100, -1),
Err(VaultError::InvalidSharePrice)
);
}

#[test]
fn conversions_round_down_in_vault_favour_and_preserve_value() {
let env = Env::default();
let prices = [
wad(1) / 2, // 0.5 NAV
wad(1), // 1.0 par NAV
3 * wad(1) / 2, // 1.5 NAV
wad(2), // 2.0 NAV
1234567890123456789i128, // fractional irregular NAV
];

let amounts = [1i128, 10, 100, 1_001, 100_000, 10_000_000_000];

for &price in &prices {
for &assets in &amounts {
let shares = DirectUnitPricing::deposit_shares(&env, assets, price).unwrap();

// Floor check for deposit: shares * price <= assets * WAD_SCALE
assert!(
shares * price <= assets * WAD_SCALE,
"deposit minted too many shares: {shares} * {price} > {assets} * WAD"
);

// Round trip: redeeming shares should never yield more assets than deposited
let redeemed = DirectUnitPricing::redeem_assets(&env, shares, price).unwrap();
assert!(
redeemed <= assets,
"round-trip value created: {redeemed} > {assets}"
);

// Floor check for redeem: redeemed * WAD_SCALE <= shares * price
assert!(
redeemed * WAD_SCALE <= shares * price,
"redeem paid out too many assets: {redeemed} * WAD > {shares} * {price}"
);
}
}
}
Loading