diff --git a/rust/CHANGELOG.md b/rust/CHANGELOG.md index 24b9438..07c2f47 100644 --- a/rust/CHANGELOG.md +++ b/rust/CHANGELOG.md @@ -6,13 +6,17 @@ All notable changes to this project will be documented in this file. ### Fixed - reCLAMM (v1 + v2) no longer panics on off-centre pools with small balances that truncate the invariant to zero; the swap path now returns `PoolError::ZeroInvariant`. +- QuantAMM weight interpolation and reCLAMM (v1 + v2) price-range updates no longer panic when `current_timestamp < last_update` / `last_timestamp` (e.g. backfill / reorg); they return `PoolError::TimestampBeforeLastUpdate`. - Fixed-point math helpers (`mul_*_fixed`, `div_*_fixed`, `mul_div_up_fixed`) now detect `U256` overflow via `checked_mul` and return `Err(PoolError::MathOverflow)` instead of panicking or silently producing masked values. - Removed crate-wide `.unwrap_or(U256::ZERO)` / `.unwrap_or(WAD)` (and related panic-on-err) masking of math-helper `Result`s; failures now propagate as `Err`. +- Stable `compute_invariant` / `compute_balance` no longer panic on a zero token balance (or zero invariant); they return `Err(PoolError::StableZeroBalance)` / `Err(PoolError::ZeroInvariant)`. All-zero balances still return `Ok(0)` to match the reference implementations. ### Changed (BREAKING) +- Renamed `compute_and_charge_aggregate_swap_fees` → `compute_and_charge_aggregate_swap_fees_raw` to make clear it returns the fee in the token's raw units (not scaled-18). - `compute_current_virtual_balances` and reCLAMM math helpers now return `Result<_, PoolError>` instead of bare tuples. - Several helpers changed from bare return types to `Result<_, PoolError>`: `compute_fourth_root_price_ratio`, `compute_centeredness`, `compute_invariant` (reCLAMM v1/v2), `_convert_to_shares` / `_convert_to_assets`, `calculate_value_change_progress` / `interpolate_value` / `get_normalized_weights` (LBP), `calculate_block_normalised_weight` (QuantAMM, **pub**), and `gyro_pool_math_sqrt`. - Behavioural change: inputs that previously produced wrong numeric values (via zero/WAD fallbacks) now surface as `Err`. Callers using `?` are unaffected; callers doing `.unwrap()` will crash where they previously consumed wrong data. +- Public APIs that returned `Result<_, String>` now return `Result<_, PoolError>`: reCLAMM/v2 `compute_out_given_in` / `compute_in_given_out`, `swap_reclamm_to_price`, `calculate_buffer_amounts`, and `erc4626_buffer_wrap_or_unwrap`. New variants: `ReClammNegativeAmountOut`, `BufferWrapAmountTooSmall`, `Erc4626ExceededMaxDeposit`, `Erc4626ExceededMaxMint`. Removed the `From` / `From<&str>` impls for `PoolError`. ## [0.4.1] - 2025-11-20 diff --git a/rust/src/common/errors.rs b/rust/src/common/errors.rs index cb1a5c8..a219d24 100644 --- a/rust/src/common/errors.rs +++ b/rust/src/common/errors.rs @@ -1,5 +1,6 @@ //! Custom error types for the Balancer maths library +use alloy_primitives::U256; use std::fmt; /// Errors that can occur during pool operations @@ -87,7 +88,31 @@ pub enum PoolError { /// Stable invariant didn't converge StableInvariantDidntConverge, + /// Stable math received a zero token balance (invariant undefined unless all balances are zero) + StableZeroBalance, + TokenAmountOutIsGreaterThanBalance, + + /// Quoting at a timestamp before the pool's last update (e.g. backfill / reorg) + TimestampBeforeLastUpdate, + + /// reCLAMM computed a negative amount out (invariant inconsistency) + ReClammNegativeAmountOut, + + /// ERC4626 buffer wrap amount below the minimum safe threshold + BufferWrapAmountTooSmall, + + /// ERC4626 deposit exceeds the vault's maxDeposit limit + Erc4626ExceededMaxDeposit { + requested: U256, + max: U256, + }, + + /// ERC4626 mint exceeds the vault's maxMint limit + Erc4626ExceededMaxMint { + requested: U256, + max: U256, + }, } impl fmt::Display for PoolError { @@ -132,23 +157,27 @@ impl fmt::Display for PoolError { PoolError::StableInvariantDidntConverge => { write!(f, "Stable invariant didn't converge") } + PoolError::StableZeroBalance => { + write!(f, "Stable math undefined for zero token balance") + } PoolError::TokenAmountOutIsGreaterThanBalance => { write!(f, "Token amount out is greater than balance") } + PoolError::TimestampBeforeLastUpdate => { + write!(f, "Timestamp is before the pool's last update") + } + PoolError::ReClammNegativeAmountOut => { + write!(f, "reClammMath: NegativeAmountOut") + } + PoolError::BufferWrapAmountTooSmall => write!(f, "wrapAmountTooSmall"), + PoolError::Erc4626ExceededMaxDeposit { requested, max } => { + write!(f, "ERC4626ExceededMaxDeposit {} {}", requested, max) + } + PoolError::Erc4626ExceededMaxMint { requested, max } => { + write!(f, "ERC4626ExceededMaxMint {} {}", requested, max) + } } } } impl std::error::Error for PoolError {} - -impl From for PoolError { - fn from(msg: String) -> Self { - PoolError::Custom(msg) - } -} - -impl From<&str> for PoolError { - fn from(msg: &str) -> Self { - PoolError::Custom(msg.to_string()) - } -} diff --git a/rust/src/common/mod.rs b/rust/src/common/mod.rs index 1aa23cb..adcc8cf 100644 --- a/rust/src/common/mod.rs +++ b/rust/src/common/mod.rs @@ -19,7 +19,7 @@ pub use types::{ Rounding, SwapInput, SwapKind, SwapParams, SwapResult, }; pub use utils::{ - compute_and_charge_aggregate_swap_fees, copy_to_scaled18_apply_rate_round_down_array, + compute_and_charge_aggregate_swap_fees_raw, copy_to_scaled18_apply_rate_round_down_array, copy_to_scaled18_apply_rate_round_up_array, find_case_insensitive_index_in_list, get_single_input_index, is_same_address, require_unbalanced_liquidity_enabled, to_raw_undo_rate_round_down, to_raw_undo_rate_round_up, to_scaled_18_apply_rate_round_down, diff --git a/rust/src/common/utils.rs b/rust/src/common/utils.rs index 6fd87b9..033f775 100644 --- a/rust/src/common/utils.rs +++ b/rust/src/common/utils.rs @@ -105,8 +105,10 @@ pub fn copy_to_scaled18_apply_rate_round_up_array( Ok(scaled_amounts) } -/// Compute and charge aggregate swap fees -pub fn compute_and_charge_aggregate_swap_fees( +/// Compute and charge aggregate swap fees. +/// +/// Returns the aggregate swap fee amount in the token's raw units (not scaled-18). +pub fn compute_and_charge_aggregate_swap_fees_raw( swap_fee_amount_scaled18: &U256, aggregate_swap_fee_percentage: &U256, decimal_scaling_factors: &[U256], diff --git a/rust/src/pools/buffer/buffer_math.rs b/rust/src/pools/buffer/buffer_math.rs index 9798d12..566cda2 100644 --- a/rust/src/pools/buffer/buffer_math.rs +++ b/rust/src/pools/buffer/buffer_math.rs @@ -1,3 +1,4 @@ +use crate::common::errors::PoolError; use crate::common::maths::{div_down_fixed, div_up_fixed, mul_down_fixed, mul_up_fixed}; use crate::common::types::{Rounding, SwapKind}; use crate::pools::buffer::enums::WrappingDirection; @@ -22,7 +23,7 @@ pub fn calculate_buffer_amounts( rate: &U256, max_deposit: Option<&U256>, max_mint: Option<&U256>, -) -> Result { +) -> Result { match direction { WrappingDirection::Wrap => { // Amount in is underlying tokens, amount out is wrapped tokens @@ -31,26 +32,23 @@ pub fn calculate_buffer_amounts( // previewDeposit let max_assets = max_deposit.unwrap_or(&U256::MAX); if amount_raw > max_assets { - return Err(format!( - "ERC4626ExceededMaxDeposit {} {}", - amount_raw, max_assets - )); + return Err(PoolError::Erc4626ExceededMaxDeposit { + requested: *amount_raw, + max: *max_assets, + }); } _convert_to_shares(amount_raw, rate, Rounding::RoundDown) - .map_err(|e| e.to_string()) } SwapKind::GivenOut => { // previewMint let max_shares = max_mint.unwrap_or(&U256::MAX); if amount_raw > max_shares { - return Err(format!( - "ERC4626ExceededMaxMint {} {}", - amount_raw, - max_mint.unwrap_or(&U256::ZERO) - )); + return Err(PoolError::Erc4626ExceededMaxMint { + requested: *amount_raw, + max: *max_mint.unwrap_or(&U256::ZERO), + }); } _convert_to_assets(amount_raw, rate, Rounding::RoundUp) - .map_err(|e| e.to_string()) } } } @@ -60,12 +58,10 @@ pub fn calculate_buffer_amounts( SwapKind::GivenIn => { // previewRedeem _convert_to_assets(amount_raw, rate, Rounding::RoundDown) - .map_err(|e| e.to_string()) } SwapKind::GivenOut => { // previewWithdraw _convert_to_shares(amount_raw, rate, Rounding::RoundUp) - .map_err(|e| e.to_string()) } } } @@ -73,11 +69,7 @@ pub fn calculate_buffer_amounts( } /// Convert assets to shares -fn _convert_to_shares( - assets: &U256, - rate: &U256, - rounding: Rounding, -) -> Result { +fn _convert_to_shares(assets: &U256, rate: &U256, rounding: Rounding) -> Result { match rounding { Rounding::RoundUp => div_up_fixed(assets, rate), Rounding::RoundDown => div_down_fixed(assets, rate), @@ -85,11 +77,7 @@ fn _convert_to_shares( } /// Convert shares to assets -fn _convert_to_assets( - shares: &U256, - rate: &U256, - rounding: Rounding, -) -> Result { +fn _convert_to_assets(shares: &U256, rate: &U256, rounding: Rounding) -> Result { match rounding { Rounding::RoundUp => mul_up_fixed(shares, rate), Rounding::RoundDown => mul_down_fixed(shares, rate), diff --git a/rust/src/pools/buffer/erc4626_buffer_wrap_or_unwrap.rs b/rust/src/pools/buffer/erc4626_buffer_wrap_or_unwrap.rs index adb3078..5cbe9c9 100644 --- a/rust/src/pools/buffer/erc4626_buffer_wrap_or_unwrap.rs +++ b/rust/src/pools/buffer/erc4626_buffer_wrap_or_unwrap.rs @@ -1,5 +1,6 @@ //! ERC4626 Buffer wrap or unwrap function +use crate::common::errors::PoolError; use crate::common::types::SwapInput; use crate::pools::buffer::buffer_data::BufferState; use crate::pools::buffer::buffer_math::calculate_buffer_amounts; @@ -19,12 +20,12 @@ pub const _MINIMUM_WRAP_AMOUNT: U256 = uint!(1000_U256); pub fn erc4626_buffer_wrap_or_unwrap( swap_input: &SwapInput, pool_state: &BufferState, -) -> Result { +) -> Result { if swap_input.amount_raw < _MINIMUM_WRAP_AMOUNT { // If amount given is too small, rounding issues can be introduced that favors the user and can drain // the buffer. _MINIMUM_WRAP_AMOUNT prevents it. Most tokens have protections against it already, this // is just an extra layer of security. - return Err("wrapAmountTooSmall".to_string()); + return Err(PoolError::BufferWrapAmountTooSmall); } // Determine wrapping direction based on token addresses diff --git a/rust/src/pools/quantamm/quantamm_pool.rs b/rust/src/pools/quantamm/quantamm_pool.rs index 8a8ffd8..9d1df67 100644 --- a/rust/src/pools/quantamm/quantamm_pool.rs +++ b/rust/src/pools/quantamm/quantamm_pool.rs @@ -69,7 +69,9 @@ impl QuantAmmPool { multiplier_time = *last_interop_time; } - let time_since_last_update = multiplier_time - last_update_time; + let time_since_last_update = multiplier_time + .checked_sub(*last_update_time) + .ok_or(PoolError::TimestampBeforeLastUpdate)?; let mut normalized_weights = Vec::with_capacity(base_weights.len()); diff --git a/rust/src/pools/reclamm/reclamm_math.rs b/rust/src/pools/reclamm/reclamm_math.rs index a7b2ad6..0adbe3f 100644 --- a/rust/src/pools/reclamm/reclamm_math.rs +++ b/rust/src/pools/reclamm/reclamm_math.rs @@ -178,6 +178,12 @@ fn compute_virtual_balances_updating_price_range( current_timestamp: &U256, last_timestamp: &U256, ) -> Result<(U256, U256), PoolError> { + // Fail before price-ratio math when quoting a block older than the last update. + let time_difference = current_timestamp + .checked_sub(*last_timestamp) + .ok_or(PoolError::TimestampBeforeLastUpdate)?; + let time_difference_wad = time_difference * WAD; + let sqrt_price_ratio = sqrt( &(compute_price_ratio(balances_scaled_18, virtual_balance_a, virtual_balance_b)? * WAD), ); @@ -195,11 +201,6 @@ fn compute_virtual_balances_updating_price_range( }; // Vb = Vb * (dailyPriceShiftBase)^(T_curr - T_last) - let time_difference = current_timestamp - .checked_sub(*last_timestamp) - .ok_or(PoolError::MathOverflow)?; - let time_difference_wad = time_difference * WAD; - let shift_factor = pow(daily_price_shift_base, &time_difference_wad)?; let virtual_balance_overvalued = mul_down_fixed(&virtual_balance_overvalued, &shift_factor)?; @@ -359,7 +360,7 @@ pub fn compute_out_given_in( token_in_index: usize, token_out_index: usize, amount_given_scaled_18: &U256, -) -> Result { +) -> Result { let (virtual_balance_token_in, virtual_balance_token_out) = if token_in_index == 0 { (virtual_balance_a, virtual_balance_b) } else { @@ -372,27 +373,25 @@ pub fn compute_out_given_in( virtual_balance_a, virtual_balance_b, Rounding::RoundUp, - ) - .map_err(|e| e.to_string())?; + )?; // Total (virtual + real) token out amount that should stay in the pool after the swap let new_total_token_out_pool_balance = div_up_fixed( &invariant, &(balances_scaled_18[token_in_index] + virtual_balance_token_in + amount_given_scaled_18), - ) - .map_err(|e| e.to_string())?; + )?; let current_total_token_out_pool_balance = balances_scaled_18[token_out_index] + virtual_balance_token_out; if new_total_token_out_pool_balance > current_total_token_out_pool_balance { - return Err("reClammMath: NegativeAmountOut".to_string()); + return Err(PoolError::ReClammNegativeAmountOut); } let amount_out_scaled_18 = current_total_token_out_pool_balance - new_total_token_out_pool_balance; if amount_out_scaled_18 > balances_scaled_18[token_out_index] { - return Err("reClammMath: AmountOutGreaterThanBalance".to_string()); + return Err(PoolError::TokenAmountOutIsGreaterThanBalance); } Ok(amount_out_scaled_18) @@ -406,9 +405,9 @@ pub fn compute_in_given_out( token_in_index: usize, token_out_index: usize, amount_out_scaled_18: &U256, -) -> Result { +) -> Result { if amount_out_scaled_18 > &balances_scaled_18[token_out_index] { - return Err("reClammMath: AmountOutGreaterThanBalance".to_string()); + return Err(PoolError::TokenAmountOutIsGreaterThanBalance); } // Round up, so the swapper absorbs any imprecision due to rounding @@ -417,8 +416,7 @@ pub fn compute_in_given_out( virtual_balance_a, virtual_balance_b, Rounding::RoundUp, - ) - .map_err(|e| e.to_string())?; + )?; let (virtual_balance_token_in, virtual_balance_token_out) = if token_in_index == 0 { (virtual_balance_a, virtual_balance_b) @@ -430,9 +428,7 @@ pub fn compute_in_given_out( let amount_in_scaled_18 = div_up_fixed( &invariant, &(balances_scaled_18[token_out_index] + virtual_balance_token_out - amount_out_scaled_18), - ) - .map_err(|e| e.to_string())? - - balances_scaled_18[token_in_index] + )? - balances_scaled_18[token_in_index] - virtual_balance_token_in; Ok(amount_in_scaled_18) diff --git a/rust/src/pools/reclamm/reclamm_pool.rs b/rust/src/pools/reclamm/reclamm_pool.rs index 043f454..f75fed6 100644 --- a/rust/src/pools/reclamm/reclamm_pool.rs +++ b/rust/src/pools/reclamm/reclamm_pool.rs @@ -67,8 +67,7 @@ impl PoolBase for ReClammPool { swap_params.token_in_index, swap_params.token_out_index, &swap_params.amount_scaled_18, - ) - .map_err(|_| PoolError::InvalidSwapParameters)?; + )?; Ok(amount_calculated_scaled_18) } @@ -80,8 +79,7 @@ impl PoolBase for ReClammPool { swap_params.token_in_index, swap_params.token_out_index, &swap_params.amount_scaled_18, - ) - .map_err(|_| PoolError::InvalidSwapParameters)?; + )?; Ok(amount_calculated_scaled_18) } diff --git a/rust/src/pools/reclamm/reclamm_pricing.rs b/rust/src/pools/reclamm/reclamm_pricing.rs index 4b013bf..089ab91 100644 --- a/rust/src/pools/reclamm/reclamm_pricing.rs +++ b/rust/src/pools/reclamm/reclamm_pricing.rs @@ -1,3 +1,4 @@ +use crate::common::errors::PoolError; use alloy_primitives::U256; /// Result struct for swap to target price calculation @@ -67,13 +68,15 @@ pub fn swap_reclamm_to_price( _protocol_fee_percentage: &U256, _pool_creator_fee_percentage: &U256, target_price_scaled_18: &U256, -) -> Result { +) -> Result { // Input validation if balances_live_scaled_18.len() != 2 || current_virtual_balances.len() != 2 || token_rates.len() != 2 { - return Err("Invalid input: arrays must have length 2".to_string()); + return Err(PoolError::InvalidInput( + "arrays must have length 2".to_string(), + )); } // Convert U256 inputs to f64 @@ -92,13 +95,15 @@ pub fn swap_reclamm_to_price( // Validate non-zero values if balance_a + virtual_a == 0.0 || balance_b + virtual_b == 0.0 { - return Err("Invalid pool state: zero total balance".to_string()); + return Err(PoolError::InvalidInput("zero total balance".to_string())); } if rate_a == 0.0 || rate_b == 0.0 { - return Err("Invalid rates: zero rate".to_string()); + return Err(PoolError::InvalidInput("zero rate".to_string())); } if target_price <= 0.0 { - return Err("Invalid target price: must be positive".to_string()); + return Err(PoolError::InvalidInput( + "target price must be positive".to_string(), + )); } // Calculate invariant and prices @@ -119,10 +124,10 @@ pub fn swap_reclamm_to_price( // Validate amounts if amount_out_scaled < 0.0 || amount_in_scaled < 0.0 { - return Err("Invalid calculation: negative amounts".to_string()); + return Err(PoolError::InvalidInput("negative amounts".to_string())); } if amount_out_scaled > balance_a { - return Err("Invalid calculation: amount out exceeds balance".to_string()); + return Err(PoolError::TokenAmountOutIsGreaterThanBalance); } Ok(SwapToTargetPriceResult { @@ -146,10 +151,10 @@ pub fn swap_reclamm_to_price( // Validate amounts if amount_out_scaled < 0.0 || amount_in_scaled < 0.0 { - return Err("Invalid calculation: negative amounts".to_string()); + return Err(PoolError::InvalidInput("negative amounts".to_string())); } if amount_out_scaled > balance_b { - return Err("Invalid calculation: amount out exceeds balance".to_string()); + return Err(PoolError::TokenAmountOutIsGreaterThanBalance); } Ok(SwapToTargetPriceResult { diff --git a/rust/src/pools/reclammv2/reclammv2_math.rs b/rust/src/pools/reclammv2/reclammv2_math.rs index 01c6eca..271ebba 100644 --- a/rust/src/pools/reclammv2/reclammv2_math.rs +++ b/rust/src/pools/reclammv2/reclammv2_math.rs @@ -181,6 +181,14 @@ fn compute_virtual_balances_updating_price_range( current_timestamp: &U256, last_timestamp: &U256, ) -> Result<(U256, U256), PoolError> { + // Fail before price-ratio math when quoting a block older than the last update. + let duration = std::cmp::min( + current_timestamp + .checked_sub(*last_timestamp) + .ok_or(PoolError::TimestampBeforeLastUpdate)?, + THIRTY_DAYS_SECONDS, + ); + let sqrt_price_ratio = sqrt_scaled_18(&compute_price_ratio( balances_scaled_18, virtual_balance_a, @@ -231,14 +239,6 @@ fn compute_virtual_balances_updating_price_range( // | Qo = Square root of price ratio | // +-----------------------------------------+ - // Cap the duration (time between operations) at 30 days, to ensure `pow_down` does not overflow. - let duration = std::cmp::min( - current_timestamp - .checked_sub(*last_timestamp) - .ok_or(PoolError::MathOverflow)?, - THIRTY_DAYS_SECONDS, - ); - let mut virtual_balance_overvalued = mul_down_fixed( &virtual_balance_overvalued, &pow_down_fixed(daily_price_shift_base, &(duration * WAD))?, @@ -415,7 +415,7 @@ pub fn compute_out_given_in( token_in_index: usize, token_out_index: usize, amount_in_scaled_18: &U256, -) -> Result { +) -> Result { let (virtual_balance_token_in, virtual_balance_token_out) = if token_in_index == 0 { (virtual_balance_a, virtual_balance_b) } else { @@ -429,7 +429,7 @@ pub fn compute_out_given_in( if amount_out_scaled_18 > balances_scaled_18[token_out_index] { // Amount out cannot be greater than the real balance of the token in the pool. - return Err("reClammMath: AmountOutGreaterThanBalance".to_string()); + return Err(PoolError::TokenAmountOutIsGreaterThanBalance); } Ok(amount_out_scaled_18) @@ -443,10 +443,10 @@ pub fn compute_in_given_out( token_in_index: usize, token_out_index: usize, amount_out_scaled_18: &U256, -) -> Result { +) -> Result { if amount_out_scaled_18 > &balances_scaled_18[token_out_index] { // Amount out cannot be greater than the real balance of the token in the pool. - return Err("reClammMath: AmountOutGreaterThanBalance".to_string()); + return Err(PoolError::TokenAmountOutIsGreaterThanBalance); } let (virtual_balance_token_in, virtual_balance_token_out) = if token_in_index == 0 { @@ -460,8 +460,7 @@ pub fn compute_in_given_out( &(balances_scaled_18[token_in_index] + virtual_balance_token_in), amount_out_scaled_18, &(balances_scaled_18[token_out_index] + virtual_balance_token_out - amount_out_scaled_18), - ) - .map_err(|e| e.to_string())?; + )?; Ok(amount_in_scaled_18) } diff --git a/rust/src/pools/stable/stable_math.rs b/rust/src/pools/stable/stable_math.rs index b966612..7298b8e 100644 --- a/rust/src/pools/stable/stable_math.rs +++ b/rust/src/pools/stable/stable_math.rs @@ -11,6 +11,11 @@ pub const _MIN_INVARIANT_RATIO: u64 = 60e16 as u64; // 60% pub const _MAX_INVARIANT_RATIO: u64 = 500e16 as u64; // 500% /// Calculate the invariant for the stable swap curve. +/// +/// Returns `Ok(0)` when every balance is zero (empty/uninitialized pool), matching the +/// Solidity/TS/Python reference. A single zero balance among non-zero others makes the +/// invariant undefined; those inputs return `Err(PoolError::StableZeroBalance)` instead of +/// panicking on divide-by-zero (Solidity would revert with `Panic(0x12)`). pub fn compute_invariant( amplification_parameter: &U256, balances: &[U256], @@ -23,6 +28,11 @@ pub fn compute_invariant( return Ok(U256::ZERO); } + // Any individual zero balance would divide by zero in the D_P product below. + if balances.iter().any(|b| b.is_zero()) { + return Err(PoolError::StableZeroBalance); + } + // Initial invariant and amplification let mut invariant = total_balance; let amp_times_total = amplification_parameter * U256::from(num_tokens); @@ -116,13 +126,23 @@ pub fn compute_in_given_exact_out( Ok(final_balance_in - balances_copy[token_index_in] + U256::ONE) } -/// Compute the balance of a token given the invariant +/// Compute the balance of a token given the invariant. +/// +/// Zero balances (or a zero invariant) make the stable balance equation undefined; return +/// a typed error rather than panicking on divide-by-zero. pub fn compute_balance( amplification_parameter: &U256, balances: &[U256], invariant: &U256, token_index: usize, ) -> Result { + if invariant.is_zero() { + return Err(PoolError::ZeroInvariant); + } + if balances.iter().any(|b| b.is_zero()) { + return Err(PoolError::StableZeroBalance); + } + let num_tokens = balances.len() as u64; let amp_times_total = amplification_parameter * U256::from(num_tokens); @@ -169,3 +189,49 @@ pub fn compute_balance( Err(PoolError::StableInvariantDidntConverge) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compute_invariant_all_zero_balances_returns_zero() { + let result = compute_invariant(&U256::from(1000u64), &[U256::ZERO, U256::ZERO]); + assert_eq!(result, Ok(U256::ZERO)); + } + + #[test] + fn compute_invariant_partial_zero_balance_errors() { + // 3-token pool with one drained balance — previously panic'd on / 0. + let balances = [ + U256::from(1_000_000u64), + U256::ZERO, + U256::from(1_000_000u64), + ]; + let result = compute_invariant(&U256::from(1000u64), &balances); + assert!(matches!(result, Err(PoolError::StableZeroBalance))); + } + + #[test] + fn compute_balance_zero_balance_errors() { + let balances = [ + U256::from(1_000_000u64), + U256::ZERO, + U256::from(1_000_000u64), + ]; + let result = compute_balance( + &U256::from(1000u64), + &balances, + &U256::from(2_000_000u64), + 0, + ); + assert!(matches!(result, Err(PoolError::StableZeroBalance))); + } + + #[test] + fn compute_balance_zero_invariant_errors() { + let balances = [U256::from(1_000_000u64), U256::from(1_000_000u64)]; + let result = compute_balance(&U256::from(1000u64), &balances, &U256::ZERO, 0); + assert!(matches!(result, Err(PoolError::ZeroInvariant))); + } +} diff --git a/rust/src/vault/add_liquidity.rs b/rust/src/vault/add_liquidity.rs index 60e2d71..80543c5 100644 --- a/rust/src/vault/add_liquidity.rs +++ b/rust/src/vault/add_liquidity.rs @@ -3,7 +3,7 @@ use crate::common::errors::PoolError; use crate::common::pool_base::PoolBase; use crate::common::utils::{ - compute_and_charge_aggregate_swap_fees, copy_to_scaled18_apply_rate_round_down_array, + compute_and_charge_aggregate_swap_fees_raw, copy_to_scaled18_apply_rate_round_down_array, get_single_input_index, require_unbalanced_liquidity_enabled, to_raw_undo_rate_round_up, }; use crate::common::{to_scaled_18_apply_rate_round_down, types::*}; @@ -110,7 +110,7 @@ pub fn add_liquidity( // A Pool's token balance always decreases after an exit // Computes protocol and pool creator fee which is eventually taken from pool balance - let aggregate_swap_fee_amount_raw = compute_and_charge_aggregate_swap_fees( + let aggregate_swap_fee_amount_raw = compute_and_charge_aggregate_swap_fees_raw( &swap_fee_amounts_scaled18[i], &base_state.aggregate_swap_fee, &base_state.scaling_factors, diff --git a/rust/src/vault/remove_liquidity.rs b/rust/src/vault/remove_liquidity.rs index 9de2f63..8bfc16a 100644 --- a/rust/src/vault/remove_liquidity.rs +++ b/rust/src/vault/remove_liquidity.rs @@ -4,7 +4,7 @@ use crate::common::errors::PoolError; use crate::common::pool_base::PoolBase; use crate::common::types::*; use crate::common::utils::{ - compute_and_charge_aggregate_swap_fees, copy_to_scaled18_apply_rate_round_up_array, + compute_and_charge_aggregate_swap_fees_raw, copy_to_scaled18_apply_rate_round_up_array, get_single_input_index, require_unbalanced_liquidity_enabled, to_raw_undo_rate_round_down, }; use crate::hooks::types::HookState; @@ -143,7 +143,8 @@ pub fn remove_liquidity( // A Pool's token balance always decreases after an exit // Computes protocol and pool creator fee which is eventually taken from pool balance - let aggregate_swap_fee_amount_scaled18 = compute_and_charge_aggregate_swap_fees( + // Aggregate fee is in raw token units (not scaled-18) + let aggregate_swap_fee_amount_raw = compute_and_charge_aggregate_swap_fees_raw( &swap_fee_amounts_scaled18[i], &base_state.aggregate_swap_fee, &base_state.scaling_factors, @@ -152,7 +153,7 @@ pub fn remove_liquidity( )?; updated_balances_live_scaled18[i] -= - amounts_out_scaled18[i] + aggregate_swap_fee_amount_scaled18; + amounts_out_scaled18[i] + aggregate_swap_fee_amount_raw; } // Call after remove liquidity hook if needed diff --git a/rust/src/vault/swap.rs b/rust/src/vault/swap.rs index c9b7fee..931401c 100644 --- a/rust/src/vault/swap.rs +++ b/rust/src/vault/swap.rs @@ -6,7 +6,7 @@ use crate::common::maths::{complement_fixed, mul_div_up_fixed, mul_up_fixed}; use crate::common::pool_base::PoolBase; use crate::common::types::*; use crate::common::utils::{ - compute_and_charge_aggregate_swap_fees, find_case_insensitive_index_in_list, + compute_and_charge_aggregate_swap_fees_raw, find_case_insensitive_index_in_list, to_raw_undo_rate_round_down, to_raw_undo_rate_round_up, to_scaled_18_apply_rate_round_down, to_scaled_18_apply_rate_round_up, }; @@ -127,8 +127,8 @@ pub fn swap( } }; - // Compute and charge aggregate swap fees - let aggregate_swap_fee_amount_scaled_18 = compute_and_charge_aggregate_swap_fees( + // Compute and charge aggregate swap fees (returned in raw token units) + let aggregate_swap_fee_amount_raw = compute_and_charge_aggregate_swap_fees_raw( &total_swap_fee_amount_scaled_18, &base_state.aggregate_swap_fee, &base_state.scaling_factors, @@ -139,11 +139,11 @@ pub fn swap( // Update balances let (balance_in_increment, balance_out_decrement) = match swap_input.swap_kind { SwapKind::GivenIn => ( - amount_given_scaled_18 - aggregate_swap_fee_amount_scaled_18, + amount_given_scaled_18 - aggregate_swap_fee_amount_raw, amount_calculated_scaled_18, ), SwapKind::GivenOut => ( - amount_calculated_scaled_18 - aggregate_swap_fee_amount_scaled_18, + amount_calculated_scaled_18 - aggregate_swap_fee_amount_raw, amount_given_scaled_18, ), }; diff --git a/rust/tests/test_reclamm_swap_to_price.rs b/rust/tests/test_reclamm_swap_to_price.rs index 2a97c5f..1091020 100644 --- a/rust/tests/test_reclamm_swap_to_price.rs +++ b/rust/tests/test_reclamm_swap_to_price.rs @@ -7,6 +7,7 @@ use balancer_maths_rust::pools::reclamm::{ SwapToTargetPriceResult, }; use balancer_maths_rust::vault::Vault; +use balancer_maths_rust::PoolError; use std::str::FromStr; /// Single source of truth for test pool data @@ -314,11 +315,10 @@ fn test_amount_out_exceeds_balance_greater() { result ); - let error_msg = result.unwrap_err(); assert!( - error_msg.contains("amount out exceeds balance"), - "Expected 'amount out exceeds balance' error, but got: {}", - error_msg + matches!(result, Err(PoolError::TokenAmountOutIsGreaterThanBalance)), + "Expected TokenAmountOutIsGreaterThanBalance, but got: {:?}", + result ); } @@ -352,10 +352,9 @@ fn test_amount_out_exceeds_balance_less() { result ); - let error_msg = result.unwrap_err(); assert!( - error_msg.contains("amount out exceeds balance"), - "Expected 'amount out exceeds balance' error, but got: {}", - error_msg + matches!(result, Err(PoolError::TokenAmountOutIsGreaterThanBalance)), + "Expected TokenAmountOutIsGreaterThanBalance, but got: {:?}", + result ); } diff --git a/rust/tests/test_timestamp_before_last_update.rs b/rust/tests/test_timestamp_before_last_update.rs new file mode 100644 index 0000000..76760c3 --- /dev/null +++ b/rust/tests/test_timestamp_before_last_update.rs @@ -0,0 +1,143 @@ +//! Regression: quoting before a pool's last update must return PoolError, not panic. + +use alloy_primitives::{I256, U256}; +use balancer_maths_rust::common::types::{BasePoolState, SwapKind, SwapParams}; +use balancer_maths_rust::pools::quantamm::{ + QuantAmmImmutable, QuantAmmMutable, QuantAmmPool, QuantAmmState, +}; +use balancer_maths_rust::pools::reclamm::{ + ReClammImmutable, ReClammMutable, ReClammPool, ReClammState, +}; +use balancer_maths_rust::pools::reclammv2::{ + ReClammV2Immutable, ReClammV2Mutable, ReClammV2Pool, ReClammV2State, +}; +use balancer_maths_rust::{PoolBase, PoolError}; + +const WAD: U256 = U256::from_limbs([1_000_000_000_000_000_000, 0, 0, 0]); +const HALF_WAD: U256 = U256::from_limbs([500_000_000_000_000_000, 0, 0, 0]); + +fn base_state(balances: Vec, pool_type: &str) -> BasePoolState { + BasePoolState { + pool_address: "0x0000000000000000000000000000000000000001".to_string(), + pool_type: pool_type.to_string(), + tokens: vec![ + "0x000000000000000000000000000000000000000a".to_string(), + "0x000000000000000000000000000000000000000b".to_string(), + ], + scaling_factors: vec![U256::from(1u64), U256::from(1u64)], + token_rates: vec![WAD, WAD], + balances_live_scaled_18: balances, + swap_fee: U256::ZERO, + aggregate_swap_fee: U256::ZERO, + total_supply: WAD, + supports_unbalanced_liquidity: false, + hook_type: None, + } +} + +/// Off-centre but non-dusty: centeredness < margin, invariant non-zero so the +/// price-range path reaches the duration subtraction. +fn off_centre_balances() -> Vec { + Vec::from([WAD, WAD / U256::from(10u64)]) +} + +fn off_centre_virtuals() -> Vec { + Vec::from([WAD, WAD]) +} + +fn swap_params(balances: Vec) -> SwapParams { + SwapParams { + swap_kind: SwapKind::GivenIn, + token_in_index: 0, + token_out_index: 1, + amount_scaled_18: U256::from(1u64), + balances_live_scaled_18: balances, + } +} + +#[test] +fn quantamm_new_returns_error_when_timestamp_before_last_update() { + let balances = vec![WAD, WAD]; + // Packed [w0, w1, m0, m1] for a 2-token pool. + let weights_and_multipliers = vec![ + I256::from_raw(HALF_WAD), + I256::from_raw(HALF_WAD), + I256::ZERO, + I256::ZERO, + ]; + let state = QuantAmmState { + base: base_state(balances, "QUANT_AMM"), + mutable: QuantAmmMutable { + first_four_weights_and_multipliers: weights_and_multipliers, + second_four_weights_and_multipliers: vec![], + last_update_time: U256::from(200u64), + last_interop_time: U256::from(300u64), + // Quoting a block earlier than the last weight update. + current_timestamp: U256::from(100u64), + }, + immutable: QuantAmmImmutable { + max_trade_size_ratio: HALF_WAD, + }, + }; + + let result = QuantAmmPool::new(state); + assert!(matches!(result, Err(PoolError::TimestampBeforeLastUpdate))); +} + +#[test] +fn reclamm_v2_on_swap_returns_error_when_timestamp_before_last_update() { + let balances = off_centre_balances(); + let state = ReClammV2State { + base: base_state(balances.clone(), "RECLAMM_V2"), + mutable: ReClammV2Mutable { + last_virtual_balances: off_centre_virtuals(), + daily_price_shift_base: WAD, + last_timestamp: U256::from(200u64), + current_timestamp: U256::from(100u64), + centeredness_margin: WAD / U256::from(2u64), + start_fourth_root_price_ratio: WAD, + end_fourth_root_price_ratio: WAD, + price_ratio_update_start_time: U256::ZERO, + price_ratio_update_end_time: U256::ZERO, + }, + immutable: ReClammV2Immutable { + pool_address: "0x0000000000000000000000000000000000000001".to_string(), + tokens: vec![ + "0x000000000000000000000000000000000000000a".to_string(), + "0x000000000000000000000000000000000000000b".to_string(), + ], + }, + }; + let pool = ReClammV2Pool::new(state); + let result = pool.on_swap(&swap_params(balances)); + assert!(matches!(result, Err(PoolError::TimestampBeforeLastUpdate))); +} + +#[test] +fn reclamm_v1_on_swap_returns_error_when_timestamp_before_last_update() { + let balances = off_centre_balances(); + let state = ReClammState { + base: base_state(balances.clone(), "RECLAMM"), + mutable: ReClammMutable { + last_virtual_balances: off_centre_virtuals(), + daily_price_shift_base: WAD, + last_timestamp: U256::from(200u64), + current_timestamp: U256::from(100u64), + centeredness_margin: WAD / U256::from(2u64), + start_fourth_root_price_ratio: WAD, + end_fourth_root_price_ratio: WAD, + price_ratio_update_start_time: U256::ZERO, + price_ratio_update_end_time: U256::ZERO, + }, + immutable: ReClammImmutable { + pool_address: "0x0000000000000000000000000000000000000001".to_string(), + tokens: vec![ + "0x000000000000000000000000000000000000000a".to_string(), + "0x000000000000000000000000000000000000000b".to_string(), + ], + }, + }; + let pool = ReClammPool::new(state); + let result = pool.on_swap(&swap_params(balances)); + assert!(matches!(result, Err(PoolError::TimestampBeforeLastUpdate))); +}