diff --git a/contracts/async-vault/src/deposit.rs b/contracts/async-vault/src/deposit.rs index 14a2b3a..bf2ead2 100644 --- a/contracts/async-vault/src/deposit.rs +++ b/contracts/async-vault/src/deposit.rs @@ -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; @@ -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 diff --git a/contracts/async-vault/src/epoch.rs b/contracts/async-vault/src/epoch.rs index aaa32a5..ce915f3 100644 --- a/contracts/async-vault/src/epoch.rs +++ b/contracts/async-vault/src/epoch.rs @@ -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; @@ -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)); diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index 33a609e..1f92a45 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -5,6 +5,7 @@ mod epoch; mod error; mod event; mod keys; +mod pricing; mod redeem; mod roles; mod state; @@ -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}; diff --git a/contracts/async-vault/src/pricing.rs b/contracts/async-vault/src/pricing.rs new file mode 100644 index 0000000..e9ca784 --- /dev/null +++ b/contracts/async-vault/src/pricing.rs @@ -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; + + /// 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 { + 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 { + 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 { + 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) + } +} diff --git a/contracts/async-vault/src/redeem.rs b/contracts/async-vault/src/redeem.rs index ceed31b..22eff69 100644 --- a/contracts/async-vault/src/redeem.rs +++ b/contracts/async-vault/src/redeem.rs @@ -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; @@ -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, diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index d890a37..be3bcd5 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -8,6 +8,7 @@ mod epochs; mod multi_epoch; mod notice; mod oracle_pricing; +mod pricing; mod redeem; mod supply; mod timing; @@ -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 { diff --git a/contracts/async-vault/src/test/pricing.rs b/contracts/async-vault/src/test/pricing.rs new file mode 100644 index 0000000..2576ef7 --- /dev/null +++ b/contracts/async-vault/src/test/pricing.rs @@ -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}" + ); + } + } +}