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
16 changes: 16 additions & 0 deletions rust/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@

All notable changes to this project will be documented in this file.

## [0.5.0] - 2026-08-11

### 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

### Changed
Expand Down
2 changes: 1 addition & 1 deletion rust/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "balancer-maths-rust"
version = "0.4.4"
version = "0.5.0"
edition = "2021"
description = "Balancer V3 mathematics library in Rust"
license = "MIT"
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())
}
}
56 changes: 52 additions & 4 deletions rust/src/common/maths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use alloy_primitives::U256;

/// Multiply two U256s and round up
pub fn mul_up_fixed(a: &U256, b: &U256) -> Result<U256, PoolError> {
let product = a * b;
let product = a.checked_mul(*b).ok_or(PoolError::MathOverflow)?;
if product.is_zero() {
return Ok(U256::ZERO);
}
Expand All @@ -23,7 +23,7 @@ pub fn div_up_fixed(a: &U256, b: &U256) -> Result<U256, PoolError> {

/// Multiply two U256s and round down
pub fn mul_down_fixed(a: &U256, b: &U256) -> Result<U256, PoolError> {
let product = a * b;
let product = a.checked_mul(*b).ok_or(PoolError::MathOverflow)?;
let result = product / WAD;
Ok(result)
}
Expand All @@ -37,7 +37,7 @@ pub fn div_down_fixed(a: &U256, b: &U256) -> Result<U256, PoolError> {
return Err(PoolError::MathOverflow);
}

let a_inflated = a * WAD;
let a_inflated = a.checked_mul(WAD).ok_or(PoolError::MathOverflow)?;
let result = a_inflated / b;
Ok(result)
}
Expand All @@ -53,7 +53,7 @@ pub fn div_up(a: &U256, b: &U256) -> Result<U256, PoolError> {

/// Multiply and divide with up rounding
pub fn mul_div_up_fixed(a: &U256, b: &U256, c: &U256) -> Result<U256, PoolError> {
let product = a * b;
let product = a.checked_mul(*b).ok_or(PoolError::MathOverflow)?;
if product.is_zero() {
return Ok(U256::ZERO);
}
Expand Down Expand Up @@ -129,3 +129,51 @@ pub fn complement_fixed(x: &U256) -> Result<U256, PoolError> {
Ok(U256::ZERO)
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn mul_down_fixed_overflow_returns_err() {
let a = U256::MAX;
let b = U256::from(2u64);
assert!(matches!(
mul_down_fixed(&a, &b),
Err(PoolError::MathOverflow)
));
}

#[test]
fn mul_up_fixed_overflow_returns_err() {
let a = U256::MAX;
let b = U256::from(2u64);
assert!(matches!(mul_up_fixed(&a, &b), Err(PoolError::MathOverflow)));
}

#[test]
fn mul_div_up_fixed_overflow_returns_err() {
let a = U256::MAX;
let b = U256::from(2u64);
assert!(matches!(
mul_div_up_fixed(&a, &b, &WAD),
Err(PoolError::MathOverflow)
));
}

#[test]
fn div_down_fixed_inflated_overflow_returns_err() {
// a * WAD overflows when a is large enough
let a = U256::MAX;
assert!(matches!(
div_down_fixed(&a, &U256::from(1u64)),
Err(PoolError::MathOverflow)
));
}

#[test]
fn mul_down_fixed_normal_ok() {
let result = mul_down_fixed(&WAD, &WAD).unwrap();
assert_eq!(result, WAD);
}
}
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
46 changes: 34 additions & 12 deletions rust/src/hooks/akron/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,31 +117,53 @@ impl HookBase for AkronHook {
match hook_state {
HookState::Akron(state) => {
let calculated_swap_fee_percentage = if swap_params.swap_kind == SwapKind::GivenIn {
let exponent = div_down_fixed(
let Ok(exponent) = div_down_fixed(
&state.weights[swap_params.token_in_index],
&state.weights[swap_params.token_out_index],
)
.unwrap_or(U256::ZERO);
) else {
return DynamicSwapFeeResult {
success: false,
dynamic_swap_fee: U256::ZERO,
};
};

Self::compute_swap_fee_percentage_given_exact_in(
match Self::compute_swap_fee_percentage_given_exact_in(
&swap_params.balances_live_scaled_18[swap_params.token_in_index],
&exponent,
&swap_params.amount_scaled_18,
)
.unwrap_or(U256::ZERO)
) {
Ok(fee) => fee,
Err(_) => {
return DynamicSwapFeeResult {
success: false,
dynamic_swap_fee: U256::ZERO,
};
}
}
} else {
let exponent = div_up_fixed(
let Ok(exponent) = div_up_fixed(
&state.weights[swap_params.token_out_index],
&state.weights[swap_params.token_in_index],
)
.unwrap_or(U256::ZERO);
) else {
return DynamicSwapFeeResult {
success: false,
dynamic_swap_fee: U256::ZERO,
};
};

Self::compute_swap_fee_percentage_given_exact_out(
match Self::compute_swap_fee_percentage_given_exact_out(
&swap_params.balances_live_scaled_18[swap_params.token_out_index],
&exponent,
&swap_params.amount_scaled_18,
)
.unwrap_or(U256::ZERO)
) {
Ok(fee) => fee,
Err(_) => {
return DynamicSwapFeeResult {
success: false,
dynamic_swap_fee: U256::ZERO,
};
}
}
};

// Charge the static or calculated fee, whichever is greater
Expand Down
10 changes: 7 additions & 3 deletions rust/src/hooks/exit_fee/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,15 @@ impl HookBase for ExitFeeHook {
if state.remove_liquidity_hook_fee_percentage > U256::ZERO {
// Charge fees proportional to amounts out of each token
for i in 0..amounts_out_raw.len() {
let hook_fee = mul_down_fixed(
let Ok(hook_fee) = mul_down_fixed(
&amounts_out_raw[i],
&state.remove_liquidity_hook_fee_percentage,
)
.unwrap_or(U256::ZERO);
) else {
return AfterRemoveLiquidityResult {
success: false,
hook_adjusted_amounts_out_raw: amounts_out_raw.to_vec(),
};
};

accrued_fees[i] = hook_fee;
hook_adjusted_amounts_out_raw[i] -= hook_fee;
Expand Down
40 changes: 20 additions & 20 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,24 +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,
});
}
Ok(_convert_to_shares(amount_raw, rate, Rounding::RoundDown))
_convert_to_shares(amount_raw, rate, Rounding::RoundDown)
}
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),
});
}
Ok(_convert_to_assets(amount_raw, rate, Rounding::RoundUp))
_convert_to_assets(amount_raw, rate, Rounding::RoundUp)
}
}
}
Expand All @@ -57,29 +57,29 @@ pub fn calculate_buffer_amounts(
match kind {
SwapKind::GivenIn => {
// previewRedeem
Ok(_convert_to_assets(amount_raw, rate, Rounding::RoundDown))
_convert_to_assets(amount_raw, rate, Rounding::RoundDown)
}
SwapKind::GivenOut => {
// previewWithdraw
Ok(_convert_to_shares(amount_raw, rate, Rounding::RoundUp))
_convert_to_shares(amount_raw, rate, Rounding::RoundUp)
}
}
}
}
}

/// Convert assets to shares
fn _convert_to_shares(assets: &U256, rate: &U256, rounding: Rounding) -> U256 {
fn _convert_to_shares(assets: &U256, rate: &U256, rounding: Rounding) -> Result<U256, PoolError> {
match rounding {
Rounding::RoundUp => div_up_fixed(assets, rate).unwrap_or(U256::ZERO),
Rounding::RoundDown => div_down_fixed(assets, rate).unwrap_or(U256::ZERO),
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) -> U256 {
fn _convert_to_assets(shares: &U256, rate: &U256, rounding: Rounding) -> Result<U256, PoolError> {
match rounding {
Rounding::RoundUp => mul_up_fixed(shares, rate).unwrap_or(U256::ZERO),
Rounding::RoundDown => mul_down_fixed(shares, rate).unwrap_or(U256::ZERO),
Rounding::RoundUp => mul_up_fixed(shares, rate),
Rounding::RoundDown => mul_down_fixed(shares, rate),
}
}
Loading
Loading