diff --git a/rust/CHANGELOG.md b/rust/CHANGELOG.md index 6500459..032d99b 100644 --- a/rust/CHANGELOG.md +++ b/rust/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to this project will be documented in this file. +## [0.4.1] - 2025-11-13 + +### Changed +- Add reCLAMM pricing functions. + ## [0.4.0] - 2025-11-13 ### Changed diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 58a98b1..5a27be6 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "balancer-maths-rust" -version = "0.4.0" +version = "0.4.1" edition = "2021" description = "Balancer V3 mathematics library in Rust" license = "MIT" @@ -17,6 +17,7 @@ serde_json = "1.0" [dev-dependencies] # Only for testing +paste = "1.0" [lib] name = "balancer_maths_rust" diff --git a/rust/src/pools/reclamm/mod.rs b/rust/src/pools/reclamm/mod.rs index f275734..b06c1fe 100644 --- a/rust/src/pools/reclamm/mod.rs +++ b/rust/src/pools/reclamm/mod.rs @@ -1,7 +1,9 @@ pub mod reclamm_data; pub mod reclamm_math; pub mod reclamm_pool; +pub mod reclamm_pricing; pub use reclamm_data::*; pub use reclamm_math::*; pub use reclamm_pool::*; +pub use reclamm_pricing::*; diff --git a/rust/src/pools/reclamm/reclamm_pricing.rs b/rust/src/pools/reclamm/reclamm_pricing.rs new file mode 100644 index 0000000..4df1f3f --- /dev/null +++ b/rust/src/pools/reclamm/reclamm_pricing.rs @@ -0,0 +1,167 @@ +use alloy_primitives::U256; + +/// Result struct for swap to target price calculation +#[derive(Debug, Clone)] +pub struct SwapToTargetPriceResult { + pub token_in_index: usize, + pub token_out_index: usize, + pub amount_in_raw: U256, + pub amount_out_raw: U256, +} + +/// Helper function to convert U256 to f64 +/// Handles large numbers by converting to string and parsing +fn u256_to_f64(value: &U256) -> f64 { + // For values that fit in u128, use direct conversion + if let Ok(val) = u128::try_from(*value) { + val as f64 + } else { + // For very large numbers, convert via string + // This may lose precision but is acceptable for our use case + value.to_string().parse::().unwrap_or(f64::MAX) + } +} + +/// Calculate current ReCLAMM price +/// Returns price in e18 format: (balanceB + virtualB) / (balanceA + virtualA) +pub fn calculate_reclamm_price( + balances_live_scaled_18: &[U256], + current_virtual_balances: &[U256], +) -> U256 { + // Convert to f64 + let balance_a = u256_to_f64(&balances_live_scaled_18[0]) / 1e18; + let balance_b = u256_to_f64(&balances_live_scaled_18[1]) / 1e18; + let virtual_a = u256_to_f64(¤t_virtual_balances[0]) / 1e18; + let virtual_b = u256_to_f64(¤t_virtual_balances[1]) / 1e18; + + // Calculate price + let price = (balance_b + virtual_b) / (balance_a + virtual_a); + + // Convert back to U256 (scaled to e18) + let price_scaled = (price * 1e18) as u128; + U256::from(price_scaled) +} + +/// Calculate swap amounts needed to reach a target price in a ReCLAMM pool +/// +/// # Arguments +/// * `token_rates` - Token rates from rate providers [rateA, rateB] in e18 +/// * `balances_live_scaled_18` - Pool balances [balanceA, balanceB] in e18 +/// * `current_virtual_balances` - Virtual balances [virtualA, virtualB] in e18 +/// * `swap_fee_percentage` - Swap fee percentage in e18 +/// * `protocol_fee_percentage` - Protocol fee percentage in e18 +/// * `pool_creator_fee_percentage` - Pool creator fee percentage in e18 +/// * `decimals_a` - Decimals for token A +/// * `decimals_b` - Decimals for token B +/// * `target_price_scaled_18` - Target price in e18 format +/// +/// # Returns +/// Result containing token indices and raw amounts for the swap +#[allow(clippy::too_many_arguments)] +pub fn swap_reclamm_to_price( + token_rates: &[U256], + balances_live_scaled_18: &[U256], + current_virtual_balances: &[U256], + swap_fee_percentage: &U256, + _protocol_fee_percentage: &U256, + _pool_creator_fee_percentage: &U256, + decimals_a: u8, + decimals_b: u8, + target_price_scaled_18: &U256, +) -> 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()); + } + + // Convert U256 inputs to f64 + let balance_a = u256_to_f64(&balances_live_scaled_18[0]) / 1e18; + let balance_b = u256_to_f64(&balances_live_scaled_18[1]) / 1e18; + let virtual_a = u256_to_f64(¤t_virtual_balances[0]) / 1e18; + let virtual_b = u256_to_f64(¤t_virtual_balances[1]) / 1e18; + let swap_fee = u256_to_f64(swap_fee_percentage) / 1e18; + // Note: protocol_fee and pool_creator_fee are not used in the calculation + // They affect fee distribution but not the swap amounts needed to reach target price + let rate_a = u256_to_f64(&token_rates[0]) / 1e18; + let rate_b = u256_to_f64(&token_rates[1]) / 1e18; + let target_price = u256_to_f64(target_price_scaled_18) / 1e18; + + // 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()); + } + if rate_a == 0.0 || rate_b == 0.0 { + return Err("Invalid rates: zero rate".to_string()); + } + if target_price <= 0.0 { + return Err("Invalid target price: must be positive".to_string()); + } + + // Calculate invariant and prices + let invariant = (balance_a + virtual_a) * (balance_b + virtual_b); + let current_price = (balance_b + virtual_b) / (balance_a + virtual_a); + let target_price_scaled = target_price * rate_a / rate_b; + + if target_price_scaled > current_price { + // tokenB in, tokenA out + let amount_out_scaled = balance_a + virtual_a - (invariant / target_price_scaled).sqrt(); + + // Calculate amount needed in the invariant math (after swap fee is removed) + let amount_in_scaled_net = + (invariant / (balance_a - amount_out_scaled + virtual_a)) - balance_b - virtual_b; + + // Convert to gross amount (before swap fee) - this is what the user provides + let amount_in_scaled = amount_in_scaled_net / (1.0 - swap_fee); + + // Validate amounts + if amount_out_scaled < 0.0 || amount_in_scaled < 0.0 { + return Err("Invalid calculation: negative amounts".to_string()); + } + if amount_out_scaled > balance_a { + return Err("Invalid calculation: amount out exceeds balance".to_string()); + } + + Ok(SwapToTargetPriceResult { + token_in_index: 1, + token_out_index: 0, + amount_in_raw: U256::from( + (amount_in_scaled * 10f64.powi(decimals_b as i32) / rate_b).ceil() as u128, + ), + amount_out_raw: U256::from( + (amount_out_scaled * 10f64.powi(decimals_a as i32) / rate_a).floor() as u128, + ), + }) + } else { + // tokenA in, tokenB out + let amount_out_scaled = balance_b + virtual_b - (invariant * target_price_scaled).sqrt(); + + // Calculate amount needed in the invariant math (after swap fee is removed) + let amount_in_scaled_net = + (invariant / (balance_b - amount_out_scaled + virtual_b)) - balance_a - virtual_a; + + // Convert to gross amount (before swap fee) - this is what the user provides + let amount_in_scaled = amount_in_scaled_net / (1.0 - swap_fee); + + // Validate amounts + if amount_out_scaled < 0.0 || amount_in_scaled < 0.0 { + return Err("Invalid calculation: negative amounts".to_string()); + } + if amount_out_scaled > balance_b { + return Err("Invalid calculation: amount out exceeds balance".to_string()); + } + + Ok(SwapToTargetPriceResult { + token_in_index: 0, + token_out_index: 1, + amount_in_raw: U256::from( + (amount_in_scaled * 10f64.powi(decimals_a as i32) / rate_a).ceil() as u128, + ), + amount_out_raw: U256::from( + (amount_out_scaled * 10f64.powi(decimals_b as i32) / rate_b).floor() as u128, + ), + }) + } +} diff --git a/rust/tests/test_reclamm_swap_to_price.rs b/rust/tests/test_reclamm_swap_to_price.rs new file mode 100644 index 0000000..bb30c60 --- /dev/null +++ b/rust/tests/test_reclamm_swap_to_price.rs @@ -0,0 +1,384 @@ +use alloy_primitives::U256; +use balancer_maths_rust::common::types::{ + BasePoolState, PoolState, PoolStateOrBuffer, SwapInput, SwapKind, +}; +use balancer_maths_rust::pools::reclamm::{ + calculate_reclamm_price, swap_reclamm_to_price, ReClammImmutable, ReClammMutable, ReClammState, + SwapToTargetPriceResult, +}; +use balancer_maths_rust::vault::Vault; +use std::str::FromStr; + +/// Single source of truth for test pool data +struct TestPool { + // Token information + tokens: Vec, + token_rates: Vec, + decimals_a: u8, + decimals_b: u8, + + // Pool balances + balances_live_scaled_18: Vec, + current_virtual_balances: Vec, + last_virtual_balances: Vec, + + // Fee information + swap_fee_percentage: U256, + protocol_fee_percentage: U256, + pool_creator_fee_percentage: U256, + aggregate_swap_fee: U256, + + // Pool metadata + pool_address: String, + pool_type: String, + scaling_factors: Vec, + total_supply: U256, + supports_unbalanced_liquidity: bool, + + // ReClamm mutable state + daily_price_shift_base: U256, + last_timestamp: U256, + current_timestamp: U256, + centeredness_margin: U256, + start_fourth_root_price_ratio: U256, + end_fourth_root_price_ratio: U256, + price_ratio_update_start_time: U256, + price_ratio_update_end_time: U256, +} + +impl TestPool { + fn new() -> Self { + // @ block 23770135 + // Dynamic: https://www.tdly.co/shared/simulation/69df26bd-13b5-4b03-ac9e-5621e846f272 + // Immutable: https://www.tdly.co/shared/simulation/a30373ac-14c6-4a80-a7ea-8fb265a9c094 + // computeCurrentVirtualBalances: https://www.tdly.co/shared/simulation/f1e1d424-7d7f-4586-bdcb-9250747a4e3b + // poolConfig: https://www.tdly.co/shared/simulation/cd9b6921-f4b6-4767-bfed-d15128f1974e + Self { + tokens: vec![ + "0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9".to_string(), + "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2".to_string(), + ], + token_rates: vec![ + U256::from_str_radix("1000000000000000000", 10).unwrap(), + U256::from_str_radix("1000000000000000000", 10).unwrap(), + ], + decimals_a: 18, + decimals_b: 18, + balances_live_scaled_18: vec![ + U256::from_str_radix("122255177411753308470", 10).unwrap(), + U256::from_str_radix("13599963412925271409", 10).unwrap(), + ], + current_virtual_balances: vec![ + U256::from_str_radix("1625276236369015815176", 10).unwrap(), + U256::from_str_radix("94519978983150350207", 10).unwrap(), + ], + last_virtual_balances: vec![ + U256::from_str_radix("1625276236369015815176", 10).unwrap(), + U256::from_str_radix("94519978983150350207", 10).unwrap(), + ], + swap_fee_percentage: U256::from_str_radix("2500000000000000", 10).unwrap(), + protocol_fee_percentage: U256::from_str_radix("500000000000000000", 10).unwrap(), + pool_creator_fee_percentage: U256::ZERO, + aggregate_swap_fee: U256::from_str("500000000000000000").unwrap(), + pool_address: "0x9d1fcf346ea1b073de4d5834e25572cc6ad71f4d".to_string(), + pool_type: "RECLAMM".to_string(), + scaling_factors: vec![U256::from(1), U256::from(1)], + total_supply: U256::from_str("70770040290965574288").unwrap(), + supports_unbalanced_liquidity: false, + daily_price_shift_base: U256::from_str("999999197747274347").unwrap(), + last_timestamp: U256::from(1762792907), + current_timestamp: U256::from(1762793123), + centeredness_margin: U256::from_str("500000000000000000").unwrap(), + start_fourth_root_price_ratio: U256::from_str("1106685929012132905").unwrap(), + end_fourth_root_price_ratio: U256::from_str("1106685929012132905").unwrap(), + price_ratio_update_start_time: U256::from(1754001203), + price_ratio_update_end_time: U256::from(1754001203), + } + } +} + +// Helper to create PoolStateOrBuffer from test pool data +fn create_pool_state_or_buffer(test_pool: &TestPool) -> PoolStateOrBuffer { + let base_pool_state = BasePoolState { + pool_address: test_pool.pool_address.clone(), + pool_type: test_pool.pool_type.clone(), + tokens: test_pool.tokens.clone(), + scaling_factors: test_pool.scaling_factors.clone(), + token_rates: test_pool.token_rates.clone(), + balances_live_scaled_18: test_pool.balances_live_scaled_18.clone(), + swap_fee: test_pool.swap_fee_percentage, + aggregate_swap_fee: test_pool.aggregate_swap_fee, + total_supply: test_pool.total_supply, + supports_unbalanced_liquidity: test_pool.supports_unbalanced_liquidity, + hook_type: None, + }; + + let re_clamm_mutable = ReClammMutable { + last_virtual_balances: test_pool.last_virtual_balances.clone(), + daily_price_shift_base: test_pool.daily_price_shift_base, + last_timestamp: test_pool.last_timestamp, + current_timestamp: test_pool.current_timestamp, + centeredness_margin: test_pool.centeredness_margin, + start_fourth_root_price_ratio: test_pool.start_fourth_root_price_ratio, + end_fourth_root_price_ratio: test_pool.end_fourth_root_price_ratio, + price_ratio_update_start_time: test_pool.price_ratio_update_start_time, + price_ratio_update_end_time: test_pool.price_ratio_update_end_time, + }; + + let re_clamm_immutable = ReClammImmutable { + pool_address: test_pool.pool_address.clone(), + tokens: test_pool.tokens.clone(), + }; + + let re_clamm_state = ReClammState { + base: base_pool_state, + mutable: re_clamm_mutable, + immutable: re_clamm_immutable, + }; + + PoolStateOrBuffer::Pool(Box::new(PoolState::ReClamm(re_clamm_state))) +} + +// Helper to get token addresses from pool state based on swap result indices +fn get_swap_tokens( + pool_state_or_buffer: &PoolStateOrBuffer, + price_result: &SwapToTargetPriceResult, +) -> (String, String) { + let tokens = match pool_state_or_buffer { + PoolStateOrBuffer::Pool(pool_state) => match pool_state.as_ref() { + PoolState::ReClamm(re_clamm_state) => &re_clamm_state.base.tokens, + _ => panic!("Expected ReClamm pool state"), + }, + PoolStateOrBuffer::Buffer(_) => panic!("Expected Pool state, not Buffer"), + }; + + let token_in = tokens[price_result.token_in_index].clone(); + let token_out = tokens[price_result.token_out_index].clone(); + + (token_in, token_out) +} + +// Helper to apply swap to balances +fn update_balances( + balances: &mut [U256], + token_in_index: usize, + token_out_index: usize, + amount_in_raw: U256, + amount_out_raw: U256, + decimals_a: u8, + decimals_b: u8, +) { + // Convert raw amounts to scaled18 + let scaling_factor_in = if token_in_index == 0 { + U256::from(10u128.pow((18 - decimals_a) as u32)) + } else { + U256::from(10u128.pow((18 - decimals_b) as u32)) + }; + + let scaling_factor_out = if token_out_index == 0 { + U256::from(10u128.pow((18 - decimals_a) as u32)) + } else { + U256::from(10u128.pow((18 - decimals_b) as u32)) + }; + + let amount_in_scaled = amount_in_raw * scaling_factor_in; + let amount_out_scaled = amount_out_raw * scaling_factor_out; + + balances[token_in_index] += amount_in_scaled; + balances[token_out_index] -= amount_out_scaled; +} + +// Helper to check if two U256 values are within tolerance (0.01%) +fn u256_equal_within_tolerance(actual: U256, expected: U256) -> bool { + let diff = if actual > expected { + actual - expected + } else { + expected - actual + }; + let tolerance = expected / U256::from(10000u128); // 0.01% + diff <= tolerance +} + +// Macro to generate similar tests for different price multipliers +macro_rules! generate_tests { + ($multiplier:expr, $suffix:ident) => { + paste::paste! { + #[test] + fn []() { + let test_pool = TestPool::new(); + let mut balances_live_scaled_18 = test_pool.balances_live_scaled_18.clone(); + + let current_price_scaled_18 = calculate_reclamm_price(&balances_live_scaled_18, &test_pool.current_virtual_balances); + let target_price_scaled_18 = current_price_scaled_18 * U256::from($multiplier * 1e18) / U256::from(1e18); + + let result = swap_reclamm_to_price( + &test_pool.token_rates, + &balances_live_scaled_18, + &test_pool.current_virtual_balances, + &test_pool.swap_fee_percentage, + &test_pool.protocol_fee_percentage, + &test_pool.pool_creator_fee_percentage, + test_pool.decimals_a, + test_pool.decimals_b, + &target_price_scaled_18, + ) + .unwrap(); + + update_balances( + &mut balances_live_scaled_18, + result.token_in_index, + result.token_out_index, + result.amount_in_raw, + result.amount_out_raw, + test_pool.decimals_a, + test_pool.decimals_b, + ); + + let new_price_scaled_18 = calculate_reclamm_price(&balances_live_scaled_18, &test_pool.current_virtual_balances); + + assert!( + u256_equal_within_tolerance(new_price_scaled_18, target_price_scaled_18), + "New price {} should equal target price {}", + new_price_scaled_18, + target_price_scaled_18 + ); + } + + #[test] + fn []() { + let test_pool = TestPool::new(); + + let current_price_scaled_18 = calculate_reclamm_price(&test_pool.balances_live_scaled_18, &test_pool.current_virtual_balances); + let target_price_scaled_18 = current_price_scaled_18 * U256::from($multiplier * 1e18) / U256::from(1e18); + + let price_result = swap_reclamm_to_price( + &test_pool.token_rates, + &test_pool.balances_live_scaled_18, + &test_pool.current_virtual_balances, + &test_pool.swap_fee_percentage, + &test_pool.protocol_fee_percentage, + &test_pool.pool_creator_fee_percentage, + test_pool.decimals_a, + test_pool.decimals_b, + &target_price_scaled_18 + ) + .unwrap(); + + // Create pool state for vault.swap + let pool_state_or_buffer = create_pool_state_or_buffer(&test_pool); + + // Create swap input + let (token_in, token_out) = get_swap_tokens(&pool_state_or_buffer, &price_result); + + let swap_input = SwapInput { + amount_raw: price_result.amount_in_raw, + token_in, + token_out, + swap_kind: SwapKind::GivenIn, + }; + + // Perform swap using vault + let vault = Vault::new(); + let computed_out_raw = vault + .swap(&swap_input, &pool_state_or_buffer, None) + .expect("Swap failed"); + + assert!( + u256_equal_within_tolerance(computed_out_raw, price_result.amount_out_raw), + "Computed amount out {} should match result amount out {} within tolerance", + computed_out_raw, + price_result.amount_out_raw + ); + } + } + }; +} +// Generate tests for price multipliers +generate_tests!(0.95, x095); +generate_tests!(0.96, x096); +generate_tests!(0.97, x097); +generate_tests!(0.98, x098); +generate_tests!(0.99, x099); +generate_tests!(1.0, x100); +generate_tests!(1.01, x101); +generate_tests!(1.02, x102); +generate_tests!(1.03, x103); +generate_tests!(1.04, x104); +generate_tests!(1.05, x105); + +#[test] +fn test_amount_out_exceeds_balance_greater() { + let test_pool = TestPool::new(); + + let current_price_scaled_18 = calculate_reclamm_price( + &test_pool.balances_live_scaled_18, + &test_pool.current_virtual_balances, + ); + // Target price 90% greater than current price + let target_price_scaled_18 = + current_price_scaled_18 * U256::from(1.9 * 1e18) / U256::from(1e18); + + let result = swap_reclamm_to_price( + &test_pool.token_rates, + &test_pool.balances_live_scaled_18, + &test_pool.current_virtual_balances, + &test_pool.swap_fee_percentage, + &test_pool.protocol_fee_percentage, + &test_pool.pool_creator_fee_percentage, + test_pool.decimals_a, + test_pool.decimals_b, + &target_price_scaled_18, + ); + + assert!( + result.is_err(), + "Expected error for 90% greater price, but got result: {:?}", + 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 + ); +} + +#[test] +fn test_amount_out_exceeds_balance_less() { + let test_pool = TestPool::new(); + + let current_price_scaled_18 = calculate_reclamm_price( + &test_pool.balances_live_scaled_18, + &test_pool.current_virtual_balances, + ); + + // Target price 50% less than current price + let target_price_scaled_18 = + current_price_scaled_18 * U256::from(0.5 * 1e18) / U256::from(1e18); + + let result = swap_reclamm_to_price( + &test_pool.token_rates, + &test_pool.balances_live_scaled_18, + &test_pool.current_virtual_balances, + &test_pool.swap_fee_percentage, + &test_pool.protocol_fee_percentage, + &test_pool.pool_creator_fee_percentage, + test_pool.decimals_a, + test_pool.decimals_b, + &target_price_scaled_18, + ); + + assert!( + result.is_err(), + "Expected error for 50% less price, but got result: {:?}", + 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 + ); +}