From 39132f60f9b41a480c2c4cf839f440fd7d62f709 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:11:57 -0300 Subject: [PATCH 1/2] feat: support flexible pricing via pricing trait Extract epoch share price resolution and conversion arithmetic into the PricingScheme trait. Provide DirectUnitPricing (default off-chain NAV) and DerivedNetAssetPricing ((attested + balance - liabilities) / supply). Conversions round in the vault's favour. Closes #72 --- contracts/async-vault/src/deposit.rs | 8 +- contracts/async-vault/src/epoch.rs | 21 ++- contracts/async-vault/src/lib.rs | 2 + contracts/async-vault/src/pricing.rs | 99 +++++++++++ contracts/async-vault/src/redeem.rs | 8 +- contracts/async-vault/src/test/mod.rs | 6 +- contracts/async-vault/src/test/pricing.rs | 197 ++++++++++++++++++++++ 7 files changed, 322 insertions(+), 19 deletions(-) create mode 100644 contracts/async-vault/src/pricing.rs create mode 100644 contracts/async-vault/src/test/pricing.rs diff --git a/contracts/async-vault/src/deposit.rs b/contracts/async-vault/src/deposit.rs index 14a2b3a..e90df57 100644 --- a/contracts/async-vault/src/deposit.rs +++ b/contracts/async-vault/src/deposit.rs @@ -1,14 +1,16 @@ 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::{DirectUnitPricing, PricingScheme}; use crate::state::{self, DepositRequest, EpochStatus}; use crate::treasury; use crate::wind_down; +type Pricing = DirectUnitPricing; + pub(crate) fn request(e: &Env, from: &Address, amount: i128) -> u64 { wind_down::refuse_if_active(e); from.require_auth(); @@ -73,8 +75,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..76f78ec 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::{DirectUnitPricing, PricingScheme}; use crate::state::{self, EpochInfo, EpochStatus}; use crate::timing::{FulfilmentTiming, StandardTiming}; use crate::wind_down; @@ -12,6 +12,9 @@ use crate::wind_down; /// The schedule this vault fulfils on. type Timing = StandardTiming; +/// The pricing scheme this vault resolves on. +type Pricing = DirectUnitPricing; + pub(crate) fn open(total_deposited: i128) -> EpochInfo { EpochInfo { status: EpochStatus::Open, @@ -98,23 +101,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..b1a012c 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::{DerivedNetAssetPricing, 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..e4e07bb --- /dev/null +++ b/contracts/async-vault/src/pricing.rs @@ -0,0 +1,99 @@ +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; +use crate::treasury; + +/// 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; + +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) + } +} + +/// Derived net-asset pricing scheme: derives the unit share price from attested +/// off-chain deployed assets plus liquid reserve minus liabilities, divided by +/// total economic supply: +/// +/// NAV = (attested_assets + balance - liabilities) * WAD_SCALE / supply +pub struct DerivedNetAssetPricing; + +impl DerivedNetAssetPricing { + /// Pure calculation of derived share price given components. + pub fn calculate_share_price( + e: &Env, + attested_assets: i128, + balance: i128, + liabilities: i128, + supply: i128, + ) -> Result { + if supply <= 0 { + return Ok(WAD_SCALE); + } + let total_assets = attested_assets + .checked_add(balance) + .ok_or(VaultError::AmountTooLarge)?; + let net_assets = total_assets + .checked_sub(liabilities) + .ok_or(VaultError::AmountTooLarge)?; + if net_assets <= 0 { + return Err(VaultError::InvalidSharePrice); + } + let price = checked_mul_div_floor(e, &net_assets, &WAD_SCALE, &supply) + .ok_or(VaultError::AmountTooLarge)?; + if price <= 0 { + return Err(VaultError::InvalidSharePrice); + } + Ok(price) + } +} + +impl PricingScheme for DerivedNetAssetPricing { + fn resolve_share_price(e: &Env) -> Result { + let feed = OracleFeedClient::new(e, &state::get_addr(e, &DataKey::Oracle)); + let attested_assets = feed.nav_per_share(); + let balance = treasury::liquid_reserve(e); + let liabilities = state::committed(e); + let supply = crate::AsyncVault::total_economic_supply(e); + Self::calculate_share_price(e, attested_assets, balance, liabilities, supply) + } +} diff --git a/contracts/async-vault/src/redeem.rs b/contracts/async-vault/src/redeem.rs index ceed31b..ad6446f 100644 --- a/contracts/async-vault/src/redeem.rs +++ b/contracts/async-vault/src/redeem.rs @@ -1,13 +1,15 @@ 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::{DirectUnitPricing, PricingScheme}; use crate::state::{self, EpochStatus, RedeemRequest}; use crate::wind_down; +type Pricing = DirectUnitPricing; + pub(crate) fn request(e: &Env, from: &Address, shares: i128) -> u64 { wind_down::refuse_if_active(e); from.require_auth(); @@ -72,8 +74,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..e10de4c 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, DerivedNetAssetPricing, 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..0bf9ff8 --- /dev/null +++ b/contracts/async-vault/src/test/pricing.rs @@ -0,0 +1,197 @@ +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 derived_pricing_at_zero_supply_is_par() { + let env = Env::default(); + let price = DerivedNetAssetPricing::calculate_share_price(&env, 0, 0, 0, 0).unwrap(); + assert_eq!(price, WAD_SCALE); + + // Any asset value with 0 supply starts at par + let price_with_assets = + DerivedNetAssetPricing::calculate_share_price(&env, 1_000_000, 500_000, 0, 0).unwrap(); + assert_eq!(price_with_assets, WAD_SCALE); +} + +#[test] +fn derived_pricing_computes_exact_ratio() { + let env = Env::default(); + + // Net assets = 1000 + 200 - 100 = 1100. Supply = 1100. Price = 1.0 (WAD_SCALE) + let p1 = DerivedNetAssetPricing::calculate_share_price( + &env, + 1_000 * WAD_SCALE, + 200 * WAD_SCALE, + 100 * WAD_SCALE, + 1_100 * WAD_SCALE, + ) + .unwrap(); + assert_eq!(p1, WAD_SCALE); + + // Net assets = 2000 + 500 - 300 = 2200. Supply = 1100. Price = 2.0 (2 * WAD_SCALE) + let p2 = DerivedNetAssetPricing::calculate_share_price( + &env, + 2_000 * WAD_SCALE, + 500 * WAD_SCALE, + 300 * WAD_SCALE, + 1_100 * WAD_SCALE, + ) + .unwrap(); + assert_eq!(p2, 2 * WAD_SCALE); +} + +#[test] +fn derived_pricing_rejects_insolvent_or_zero_net_assets() { + let env = Env::default(); + + // Liabilities equal to total assets (net assets = 0) + assert_eq!( + DerivedNetAssetPricing::calculate_share_price(&env, 1_000, 500, 1_500, 1_000), + Err(VaultError::InvalidSharePrice) + ); + + // Liabilities strictly exceed total assets (net assets < 0) + assert_eq!( + DerivedNetAssetPricing::calculate_share_price(&env, 1_000, 500, 2_000, 1_000), + Err(VaultError::InvalidSharePrice) + ); +} + +#[test] +fn derived_pricing_rejects_overflow() { + let env = Env::default(); + + // Asset overflow in checked_add + assert_eq!( + DerivedNetAssetPricing::calculate_share_price(&env, i128::MAX, 1, 0, 1_000), + Err(VaultError::AmountTooLarge) + ); + + // Net asset product overflow in checked_mul_div + assert_eq!( + DerivedNetAssetPricing::calculate_share_price(&env, i128::MAX / 2, 0, 0, 1), + Err(VaultError::AmountTooLarge) + ); +} + +#[test] +fn derived_pricing_preserves_monotonicity() { + let env = Env::default(); + let base_attested = 10_000 * WAD_SCALE; + let base_balance = 2_000 * WAD_SCALE; + let base_liabilities = 1_000 * WAD_SCALE; + let base_supply = 10_000 * WAD_SCALE; + + let base_price = DerivedNetAssetPricing::calculate_share_price( + &env, + base_attested, + base_balance, + base_liabilities, + base_supply, + ) + .unwrap(); + + // 1. Increasing liabilities strictly reduces share price + let higher_liabilities_price = DerivedNetAssetPricing::calculate_share_price( + &env, + base_attested, + base_balance, + base_liabilities + 500 * WAD_SCALE, + base_supply, + ) + .unwrap(); + assert!(higher_liabilities_price < base_price); + + // 2. Increasing balance strictly increases share price + let higher_balance_price = DerivedNetAssetPricing::calculate_share_price( + &env, + base_attested, + base_balance + 500 * WAD_SCALE, + base_liabilities, + base_supply, + ) + .unwrap(); + assert!(higher_balance_price > base_price); + + // 3. Increasing supply strictly dilutes share price + let higher_supply_price = DerivedNetAssetPricing::calculate_share_price( + &env, + base_attested, + base_balance, + base_liabilities, + base_supply + 1_000 * WAD_SCALE, + ) + .unwrap(); + assert!(higher_supply_price < base_price); +} + +#[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}" + ); + } + } +} From 1231c14b1ecd16a64740857ffa50f6e480a62366 Mon Sep 17 00:00:00 2001 From: hpmaxi <358059+hpmaxi@users.noreply.github.com> Date: Fri, 25 Sep 2026 07:37:40 -0300 Subject: [PATCH 2/2] refactor: drop the derived pricing scheme and share one pricing alias The design attests the share price itself; the derived scheme was unused and read a per-share price as total assets. --- contracts/async-vault/src/deposit.rs | 4 +- contracts/async-vault/src/epoch.rs | 5 +- contracts/async-vault/src/lib.rs | 2 +- contracts/async-vault/src/pricing.rs | 52 +-------- contracts/async-vault/src/redeem.rs | 4 +- contracts/async-vault/src/test/mod.rs | 6 +- contracts/async-vault/src/test/pricing.rs | 124 ---------------------- 7 files changed, 10 insertions(+), 187 deletions(-) diff --git a/contracts/async-vault/src/deposit.rs b/contracts/async-vault/src/deposit.rs index e90df57..bf2ead2 100644 --- a/contracts/async-vault/src/deposit.rs +++ b/contracts/async-vault/src/deposit.rs @@ -4,13 +4,11 @@ use soroban_sdk::{panic_with_error, token::TokenClient, Address, Env}; use crate::error::VaultError; use crate::event::{DepositCancelled, DepositClaimed, DepositRequested}; use crate::keys::DataKey; -use crate::pricing::{DirectUnitPricing, PricingScheme}; +use crate::pricing::{Pricing, PricingScheme}; use crate::state::{self, DepositRequest, EpochStatus}; use crate::treasury; use crate::wind_down; -type Pricing = DirectUnitPricing; - pub(crate) fn request(e: &Env, from: &Address, amount: i128) -> u64 { wind_down::refuse_if_active(e); from.require_auth(); diff --git a/contracts/async-vault/src/epoch.rs b/contracts/async-vault/src/epoch.rs index 76f78ec..ce915f3 100644 --- a/contracts/async-vault/src/epoch.rs +++ b/contracts/async-vault/src/epoch.rs @@ -4,7 +4,7 @@ use soroban_sdk::{panic_with_error, Env}; use crate::error::VaultError; use crate::event::{EpochClosed, EpochFulfilled}; use crate::keys::DataKey; -use crate::pricing::{DirectUnitPricing, PricingScheme}; +use crate::pricing::{Pricing, PricingScheme}; use crate::state::{self, EpochInfo, EpochStatus}; use crate::timing::{FulfilmentTiming, StandardTiming}; use crate::wind_down; @@ -12,9 +12,6 @@ use crate::wind_down; /// The schedule this vault fulfils on. type Timing = StandardTiming; -/// The pricing scheme this vault resolves on. -type Pricing = DirectUnitPricing; - pub(crate) fn open(total_deposited: i128) -> EpochInfo { EpochInfo { status: EpochStatus::Open, diff --git a/contracts/async-vault/src/lib.rs b/contracts/async-vault/src/lib.rs index b1a012c..1f92a45 100644 --- a/contracts/async-vault/src/lib.rs +++ b/contracts/async-vault/src/lib.rs @@ -32,7 +32,7 @@ pub use event::{ UpgradeDelaySet, UpgradeProposed, Upgraded, WindDownActivated, WindDownClaimed, WindDownDelaySet, WindDownProposalCancelled, WindDownProposed, WindDownRoundFinalized, }; -pub use pricing::{DerivedNetAssetPricing, DirectUnitPricing, PricingScheme}; +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 index e4e07bb..e9ca784 100644 --- a/contracts/async-vault/src/pricing.rs +++ b/contracts/async-vault/src/pricing.rs @@ -5,7 +5,6 @@ use stellar_contract_utils::math::{i128_fixed_point::checked_mul_div_floor, wad: use crate::error::VaultError; use crate::keys::DataKey; use crate::state; -use crate::treasury; /// 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. @@ -39,6 +38,9 @@ pub trait PricingScheme { /// 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)); @@ -49,51 +51,3 @@ impl PricingScheme for DirectUnitPricing { Ok(share_price) } } - -/// Derived net-asset pricing scheme: derives the unit share price from attested -/// off-chain deployed assets plus liquid reserve minus liabilities, divided by -/// total economic supply: -/// -/// NAV = (attested_assets + balance - liabilities) * WAD_SCALE / supply -pub struct DerivedNetAssetPricing; - -impl DerivedNetAssetPricing { - /// Pure calculation of derived share price given components. - pub fn calculate_share_price( - e: &Env, - attested_assets: i128, - balance: i128, - liabilities: i128, - supply: i128, - ) -> Result { - if supply <= 0 { - return Ok(WAD_SCALE); - } - let total_assets = attested_assets - .checked_add(balance) - .ok_or(VaultError::AmountTooLarge)?; - let net_assets = total_assets - .checked_sub(liabilities) - .ok_or(VaultError::AmountTooLarge)?; - if net_assets <= 0 { - return Err(VaultError::InvalidSharePrice); - } - let price = checked_mul_div_floor(e, &net_assets, &WAD_SCALE, &supply) - .ok_or(VaultError::AmountTooLarge)?; - if price <= 0 { - return Err(VaultError::InvalidSharePrice); - } - Ok(price) - } -} - -impl PricingScheme for DerivedNetAssetPricing { - fn resolve_share_price(e: &Env) -> Result { - let feed = OracleFeedClient::new(e, &state::get_addr(e, &DataKey::Oracle)); - let attested_assets = feed.nav_per_share(); - let balance = treasury::liquid_reserve(e); - let liabilities = state::committed(e); - let supply = crate::AsyncVault::total_economic_supply(e); - Self::calculate_share_price(e, attested_assets, balance, liabilities, supply) - } -} diff --git a/contracts/async-vault/src/redeem.rs b/contracts/async-vault/src/redeem.rs index ad6446f..22eff69 100644 --- a/contracts/async-vault/src/redeem.rs +++ b/contracts/async-vault/src/redeem.rs @@ -4,12 +4,10 @@ use soroban_sdk::{panic_with_error, token::TokenClient, Address, Env}; use crate::error::VaultError; use crate::event::{RedeemCancelled, RedeemClaimed, RedeemRequested}; use crate::keys::DataKey; -use crate::pricing::{DirectUnitPricing, PricingScheme}; +use crate::pricing::{Pricing, PricingScheme}; use crate::state::{self, EpochStatus, RedeemRequest}; use crate::wind_down; -type Pricing = DirectUnitPricing; - pub(crate) fn request(e: &Env, from: &Address, shares: i128) -> u64 { wind_down::refuse_if_active(e); from.require_auth(); diff --git a/contracts/async-vault/src/test/mod.rs b/contracts/async-vault/src/test/mod.rs index e10de4c..be3bcd5 100644 --- a/contracts/async-vault/src/test/mod.rs +++ b/contracts/async-vault/src/test/mod.rs @@ -33,9 +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, DerivedNetAssetPricing, DirectUnitPricing, EpochStatus, - PricingScheme, 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 index 0bf9ff8..2576ef7 100644 --- a/contracts/async-vault/src/test/pricing.rs +++ b/contracts/async-vault/src/test/pricing.rs @@ -33,130 +33,6 @@ fn direct_unit_pricing_rejects_zero_or_negative_price() { ); } -#[test] -fn derived_pricing_at_zero_supply_is_par() { - let env = Env::default(); - let price = DerivedNetAssetPricing::calculate_share_price(&env, 0, 0, 0, 0).unwrap(); - assert_eq!(price, WAD_SCALE); - - // Any asset value with 0 supply starts at par - let price_with_assets = - DerivedNetAssetPricing::calculate_share_price(&env, 1_000_000, 500_000, 0, 0).unwrap(); - assert_eq!(price_with_assets, WAD_SCALE); -} - -#[test] -fn derived_pricing_computes_exact_ratio() { - let env = Env::default(); - - // Net assets = 1000 + 200 - 100 = 1100. Supply = 1100. Price = 1.0 (WAD_SCALE) - let p1 = DerivedNetAssetPricing::calculate_share_price( - &env, - 1_000 * WAD_SCALE, - 200 * WAD_SCALE, - 100 * WAD_SCALE, - 1_100 * WAD_SCALE, - ) - .unwrap(); - assert_eq!(p1, WAD_SCALE); - - // Net assets = 2000 + 500 - 300 = 2200. Supply = 1100. Price = 2.0 (2 * WAD_SCALE) - let p2 = DerivedNetAssetPricing::calculate_share_price( - &env, - 2_000 * WAD_SCALE, - 500 * WAD_SCALE, - 300 * WAD_SCALE, - 1_100 * WAD_SCALE, - ) - .unwrap(); - assert_eq!(p2, 2 * WAD_SCALE); -} - -#[test] -fn derived_pricing_rejects_insolvent_or_zero_net_assets() { - let env = Env::default(); - - // Liabilities equal to total assets (net assets = 0) - assert_eq!( - DerivedNetAssetPricing::calculate_share_price(&env, 1_000, 500, 1_500, 1_000), - Err(VaultError::InvalidSharePrice) - ); - - // Liabilities strictly exceed total assets (net assets < 0) - assert_eq!( - DerivedNetAssetPricing::calculate_share_price(&env, 1_000, 500, 2_000, 1_000), - Err(VaultError::InvalidSharePrice) - ); -} - -#[test] -fn derived_pricing_rejects_overflow() { - let env = Env::default(); - - // Asset overflow in checked_add - assert_eq!( - DerivedNetAssetPricing::calculate_share_price(&env, i128::MAX, 1, 0, 1_000), - Err(VaultError::AmountTooLarge) - ); - - // Net asset product overflow in checked_mul_div - assert_eq!( - DerivedNetAssetPricing::calculate_share_price(&env, i128::MAX / 2, 0, 0, 1), - Err(VaultError::AmountTooLarge) - ); -} - -#[test] -fn derived_pricing_preserves_monotonicity() { - let env = Env::default(); - let base_attested = 10_000 * WAD_SCALE; - let base_balance = 2_000 * WAD_SCALE; - let base_liabilities = 1_000 * WAD_SCALE; - let base_supply = 10_000 * WAD_SCALE; - - let base_price = DerivedNetAssetPricing::calculate_share_price( - &env, - base_attested, - base_balance, - base_liabilities, - base_supply, - ) - .unwrap(); - - // 1. Increasing liabilities strictly reduces share price - let higher_liabilities_price = DerivedNetAssetPricing::calculate_share_price( - &env, - base_attested, - base_balance, - base_liabilities + 500 * WAD_SCALE, - base_supply, - ) - .unwrap(); - assert!(higher_liabilities_price < base_price); - - // 2. Increasing balance strictly increases share price - let higher_balance_price = DerivedNetAssetPricing::calculate_share_price( - &env, - base_attested, - base_balance + 500 * WAD_SCALE, - base_liabilities, - base_supply, - ) - .unwrap(); - assert!(higher_balance_price > base_price); - - // 3. Increasing supply strictly dilutes share price - let higher_supply_price = DerivedNetAssetPricing::calculate_share_price( - &env, - base_attested, - base_balance, - base_liabilities, - base_supply + 1_000 * WAD_SCALE, - ) - .unwrap(); - assert!(higher_supply_price < base_price); -} - #[test] fn conversions_round_down_in_vault_favour_and_preserve_value() { let env = Env::default();