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
2 changes: 2 additions & 0 deletions rust/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ 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.
Expand All @@ -15,6 +16,7 @@ All notable changes to this project will be documented in this file.
- `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<String>` / `From<&str>` impls for `PoolError`.

## [0.4.1] - 2025-11-20

Expand Down
47 changes: 35 additions & 12 deletions rust/src/common/errors.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -91,6 +92,27 @@ pub enum PoolError {
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 {
Expand Down Expand Up @@ -141,20 +163,21 @@ impl fmt::Display for PoolError {
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<String> 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())
}
}
36 changes: 12 additions & 24 deletions rust/src/pools/buffer/buffer_math.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -22,7 +23,7 @@ pub fn calculate_buffer_amounts(
rate: &U256,
max_deposit: Option<&U256>,
max_mint: Option<&U256>,
) -> Result<U256, String> {
) -> Result<U256, PoolError> {
match direction {
WrappingDirection::Wrap => {
// Amount in is underlying tokens, amount out is wrapped tokens
Expand All @@ -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())
}
}
}
Expand All @@ -60,36 +58,26 @@ 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())
}
}
}
}
}

/// Convert assets to shares
fn _convert_to_shares(
assets: &U256,
rate: &U256,
rounding: Rounding,
) -> Result<U256, crate::common::errors::PoolError> {
fn _convert_to_shares(assets: &U256, rate: &U256, rounding: Rounding) -> Result<U256, PoolError> {
match rounding {
Rounding::RoundUp => div_up_fixed(assets, rate),
Rounding::RoundDown => div_down_fixed(assets, rate),
}
}

/// Convert shares to assets
fn _convert_to_assets(
shares: &U256,
rate: &U256,
rounding: Rounding,
) -> Result<U256, crate::common::errors::PoolError> {
fn _convert_to_assets(shares: &U256, rate: &U256, rounding: Rounding) -> Result<U256, PoolError> {
match rounding {
Rounding::RoundUp => mul_up_fixed(shares, rate),
Rounding::RoundDown => mul_down_fixed(shares, rate),
Expand Down
5 changes: 3 additions & 2 deletions rust/src/pools/buffer/erc4626_buffer_wrap_or_unwrap.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<U256, String> {
) -> Result<U256, PoolError> {
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
Expand Down
4 changes: 3 additions & 1 deletion rust/src/pools/quantamm/quantamm_pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down
34 changes: 15 additions & 19 deletions rust/src/pools/reclamm/reclamm_math.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
Expand All @@ -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)?;

Expand Down Expand Up @@ -359,7 +360,7 @@ pub fn compute_out_given_in(
token_in_index: usize,
token_out_index: usize,
amount_given_scaled_18: &U256,
) -> Result<U256, String> {
) -> Result<U256, PoolError> {
let (virtual_balance_token_in, virtual_balance_token_out) = if token_in_index == 0 {
(virtual_balance_a, virtual_balance_b)
} else {
Expand All @@ -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)
Expand All @@ -406,9 +405,9 @@ pub fn compute_in_given_out(
token_in_index: usize,
token_out_index: usize,
amount_out_scaled_18: &U256,
) -> Result<U256, String> {
) -> Result<U256, PoolError> {
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
Expand All @@ -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)
Expand All @@ -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)
Expand Down
6 changes: 2 additions & 4 deletions rust/src/pools/reclamm/reclamm_pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down
23 changes: 14 additions & 9 deletions rust/src/pools/reclamm/reclamm_pricing.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::common::errors::PoolError;
use alloy_primitives::U256;

/// Result struct for swap to target price calculation
Expand Down Expand Up @@ -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<SwapToTargetPriceResult, String> {
) -> Result<SwapToTargetPriceResult, PoolError> {
// 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
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
Loading
Loading