Skip to content
4 changes: 4 additions & 0 deletions rust/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>` / `From<&str>` impls for `PoolError`.

## [0.4.1] - 2025-11-20

Expand Down
53 changes: 41 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 @@ -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 {
Expand Down Expand Up @@ -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<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())
}
}
2 changes: 1 addition & 1 deletion rust/src/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions rust/src/common/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
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
Loading
Loading