Skip to main content

tycho_simulation/evm/protocol/balancer_v3/
state.rs

1//! [`BalancerV3State`] — a hybrid Balancer V3 pool: pure-Rust quote maths
2//! (`balancer_maths_rust`) over state read from the locally indexed VM storage.
3use std::any::Any;
4
5use alloy::primitives::{Address as AlloyAddress, U256};
6use balancer_maths_rust::{
7    common::{
8        maths::{div_up_fixed, mul_down_fixed, mul_up_fixed, pow_up_fixed},
9        pool_base::PoolBase,
10        types::{PoolState, SwapInput, SwapKind, SwapParams},
11        utils::{
12            compute_and_charge_aggregate_swap_fees_raw, to_raw_undo_rate_round_down,
13            to_scaled_18_apply_rate_round_down,
14        },
15        WAD as ONE_WAD_SCALED_18,
16    },
17    pools::{
18        quantamm::QuantAmmPool,
19        reclammv2::{compute_current_virtual_balances, compute_in_given_out, ReClammV2Pool},
20        stable::{self, StablePool},
21        weighted::{WeightedPool, MAX_IN_RATIO},
22    },
23    vault::swap::{swap as vault_swap, MINIMUM_TRADE_AMOUNT},
24    DefaultHook, PoolError,
25};
26use num_bigint::{BigUint, ToBigUint};
27use serde::{Deserialize, Serialize};
28use tycho_common::{
29    dto::ProtocolStateDelta,
30    models::token::Token,
31    simulation::{
32        errors::{SimulationError, TransitionError},
33        protocol_sim::{Balances, GetAmountOutResult, ProtocolSim},
34    },
35    Bytes,
36};
37
38use crate::evm::{
39    engine_db::{create_engine, SHARED_TYCHO_DB},
40    protocol::{
41        balancer_v3::vm,
42        u256_num::{biguint_to_u256, u256_to_biguint, u256_to_f64},
43        utils::add_fee_markup,
44    },
45};
46
47/// Fee and rate denominator used throughout Balancer V3 (`1e18`).
48const WAD: f64 = 1e18;
49/// Representative gas for a single-hop Balancer V3 swap. Executions observed in the
50/// `vm:balancer_v3` integration test spent 206k–242k gas including the router and executor.
51const SWAP_GAS: u64 = 210_000;
52/// Fraction of the input balance probed to approximate the marginal price (`1e-6`).
53const SPOT_PRICE_PROBE_DIVISOR: u64 = 1_000_000;
54/// Attribute the stream decoder attaches to every delta, carrying the block's timestamp.
55const BLOCK_TIMESTAMP_ATTRIBUTE: &str = "block_timestamp";
56/// Hard cap the Vault stores any balance under (`2^128 - 1`), which is what bounds a stable
57/// pool's input: `StableMath` itself has no input limit.
58const MAX_VAULT_BALANCE: U256 = U256::from_limbs([u64::MAX, u64::MAX, 0, 0]);
59/// Largest share of the output reserve a reCLAMM swap may buy (`0.99e18`), matching
60/// `_MAX_TOKEN_OUT_RATIO` in the reference implementation.
61const MAX_TOKEN_OUT_RATIO: U256 = U256::from_limbs([990_000_000_000_000_000, 0, 0, 0]);
62/// Largest ratio between any two live balances a stable-pool swap may leave behind, matching
63/// `StableMath.MAX_IMBALANCE_RATIO` (`10_000`). Added to the v3 factory generation's
64/// `StablePool.onSwap`; `balancer_maths_rust` still only models the December 2024 genesis
65/// contracts and has no such check.
66const STABLE_MAX_IMBALANCE_RATIO: U256 = U256::from_limbs([10_000, 0, 0, 0]);
67
68/// A single Balancer V3 pool quoted through `balancer_maths_rust`.
69///
70/// The storage-derived parts of the state are re-read from the VM on every
71/// [`ProtocolSim::delta_transition`] rather than patched from the delta: the values the maths
72/// needs (live balances, token rates, amplification) are derived from storage that other
73/// contracts — rate providers above all — own. What registration fixed forever (tokens, scaling
74/// factors, weights, the hook check) is kept from decode time.
75#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
76pub struct BalancerV3State {
77    /// Pool contract address (the Tycho component id).
78    pool_address: Bytes,
79    /// Token addresses in pool registration order, which the maths library indexes balances, rates
80    /// and weights by.
81    tokens: Vec<Bytes>,
82    /// Per-token minimum live balance (scaled 18, registration order) a weighted pool's own
83    /// `MinTokenBalanceLib` check enforces. Empty when no such floor applies: non-weighted pools,
84    /// and weighted generations predating the check (`getMinTokenBalances` reverts on them).
85    min_token_balances: Vec<U256>,
86    /// Timestamp of the block this state was read at. reCLAMM quotes depend on it, so it is
87    /// refreshed on every update; the other families ignore it.
88    block_timestamp: u64,
89    /// Pool state in the form the maths library consumes.
90    state: PoolState,
91}
92
93impl BalancerV3State {
94    pub(super) fn new(
95        pool_address: Bytes,
96        tokens: Vec<Bytes>,
97        min_token_balances: Vec<U256>,
98        block_timestamp: u64,
99        state: PoolState,
100    ) -> Self {
101        Self { pool_address, tokens, min_token_balances, block_timestamp, state }
102    }
103
104    /// Token addresses in pool registration order.
105    #[cfg(test)]
106    pub(super) fn token_addresses(&self) -> &[Bytes] {
107        &self.tokens
108    }
109
110    /// Reserves in each token's own units, in pool registration order.
111    #[cfg(test)]
112    pub(super) fn raw_balances(&self) -> Vec<U256> {
113        let base = self.state.base();
114        (0..base.balances_live_scaled_18.len())
115            .map(|index| raw_balance(base, index).expect("a live balance must rescale to raw"))
116            .collect()
117    }
118
119    /// Live scaled-18 balances in pool registration order.
120    #[cfg(test)]
121    pub(super) fn state_balances(&self) -> &[U256] {
122        &self
123            .state
124            .base()
125            .balances_live_scaled_18
126    }
127
128    pub(super) fn token_index(&self, token: &Bytes) -> Result<usize, SimulationError> {
129        self.tokens
130            .iter()
131            .position(|candidate| candidate == token)
132            .ok_or_else(|| {
133                SimulationError::InvalidInput(
134                    format!(
135                        "token {token} is not registered in balancer_v3 pool {}",
136                        self.pool_address
137                    ),
138                    None,
139                )
140            })
141    }
142
143    /// Builds the pool implementation the maths dispatches a swap to, which `Vault::swap` would
144    /// otherwise build itself. [`Self::spot_price`] needs it for a second reason: only `on_swap`
145    /// reports an amount before the swap fee is taken.
146    fn pool_impl(&self) -> Result<Box<dyn PoolBase>, PoolError> {
147        match &self.state {
148            PoolState::Weighted(state) => Ok(Box::new(WeightedPool::from(state.clone()))),
149            PoolState::Stable(state) => Ok(Box::new(StablePool::new(state.mutable.clone()))),
150            PoolState::ReClammV2(state) => Ok(Box::new(ReClammV2Pool::new(state.clone()))),
151            // Unlike the other families, building this resolves the pool's time-interpolated
152            // weights, which fails if the packed weight arrays are shorter than the token list.
153            PoolState::QuantAmm(state) => {
154                QuantAmmPool::new(state.clone()).map(|pool| Box::new(pool) as Box<dyn PoolBase>)
155            }
156            other => Err(PoolError::UnsupportedPoolType(other.pool_type().to_string())),
157        }
158    }
159
160    /// Swaps `amount_in` of `token_in` for `token_out`, returning the Vault's own [`PoolError`] on
161    /// failure so callers that care about a specific failure mode (see
162    /// [`ProtocolSim::get_limits`]) do not have to parse it back out of a formatted message.
163    fn vault_swap_exact_in(
164        &self,
165        amount_in: U256,
166        token_in: &Bytes,
167        token_out: &Bytes,
168    ) -> Result<U256, PoolError> {
169        let input = SwapInput {
170            amount_raw: amount_in,
171            swap_kind: SwapKind::GivenIn,
172            token_in: format!("0x{}", hex::encode(token_in)),
173            token_out: format!("0x{}", hex::encode(token_out)),
174        };
175        // `Vault::swap` takes the state by `Box<PoolState>`, cloning it on every call — which the
176        // limit searches make hundreds of. Supplying the pool implementation and hook directly is
177        // all its body does before delegating here, and lets the state be borrowed. The no-op hook
178        // is faithful because the decoder rejects pools carrying a swap hook.
179        vault_swap(&input, &self.state, self.pool_impl()?.as_ref(), &DefaultHook::new(), None)
180    }
181
182    /// Largest input the Vault accepts for a swap from `index_in` to `index_out`, in the input
183    /// token's raw units.
184    ///
185    /// Mirrors the reference implementation's `getMaxSwapAmount` for exact-in swaps. Each family
186    /// bounds it differently — see the per-family functions below; reCLAMM is bounded on the
187    /// output side, by the input that buys [`MAX_TOKEN_OUT_RATIO`] of the output reserve at the
188    /// current virtual balances.
189    fn max_swap_amount_in(
190        &self,
191        index_in: usize,
192        index_out: usize,
193    ) -> Result<U256, SimulationError> {
194        let base = self.state.base();
195        let balances = &base.balances_live_scaled_18;
196        let maths_error = |e: PoolError| {
197            SimulationError::FatalError(format!(
198                "balancer_v3 swap limit failed for pool {}: {e:?}",
199                self.pool_address
200            ))
201        };
202
203        let max_in_scaled_18 = match &self.state {
204            PoolState::Weighted(state) => {
205                self.weighted_max_swap_amount_in(index_in, index_out, state.weights())?
206            }
207            PoolState::Stable(_) => self.stable_max_swap_amount_in(index_in, index_out)?,
208            // QuantAMM is bounded in raw units rather than scaled-18 ones, so it returns straight
209            // away instead of falling through to the conversion below.
210            PoolState::QuantAmm(state) => {
211                return self.quantamm_max_swap_amount_in(
212                    index_in,
213                    index_out,
214                    &state.immutable.max_trade_size_ratio,
215                )
216            }
217            PoolState::ReClammV2(state) => {
218                let max_out_scaled_18 = mul_down_fixed(&MAX_TOKEN_OUT_RATIO, &balances[index_out])
219                    .map_err(maths_error)?;
220                let mutable = &state.mutable;
221                // A pool whose reserves and virtual balances are small enough for its invariant to
222                // round to zero has no price range to speak of, and reports as much rather than
223                // dividing by it. Recoverable: a re-seeded pool quotes again.
224                let (virtual_balance_a, virtual_balance_b, _) = compute_current_virtual_balances(
225                    &mutable.current_timestamp,
226                    balances,
227                    &mutable.last_virtual_balances[0],
228                    &mutable.last_virtual_balances[1],
229                    &mutable.daily_price_shift_base,
230                    &mutable.last_timestamp,
231                    &mutable.centeredness_margin,
232                    &mutable.start_fourth_root_price_ratio,
233                    &mutable.end_fourth_root_price_ratio,
234                    &mutable.price_ratio_update_start_time,
235                    &mutable.price_ratio_update_end_time,
236                )
237                .map_err(|e| {
238                    SimulationError::RecoverableError(format!(
239                        "balancer_v3 reCLAMM pool {} has no usable price range: {e:?}",
240                        self.pool_address
241                    ))
242                })?;
243                compute_in_given_out(
244                    balances,
245                    &virtual_balance_a,
246                    &virtual_balance_b,
247                    index_in,
248                    index_out,
249                    &max_out_scaled_18,
250                )
251                .map_err(|e| {
252                    SimulationError::FatalError(format!(
253                        "balancer_v3 swap limit failed for pool {}: {e}",
254                        self.pool_address
255                    ))
256                })?
257            }
258            other => {
259                return Err(SimulationError::FatalError(format!(
260                    "balancer_v3 pool {} holds unsupported state `{}`",
261                    self.pool_address,
262                    other.pool_type()
263                )))
264            }
265        };
266
267        to_raw_undo_rate_round_down(
268            &max_in_scaled_18,
269            &base.scaling_factors[index_in],
270            &base.token_rates[index_in],
271        )
272        .map_err(maths_error)
273    }
274
275    /// Largest raw input a QuantAMM pool accepts, found by binary search over the Vault path.
276    ///
277    /// `onSwap` applies `maxTradeSizeRatio` to *both* sides, so the input reserve's share of it is
278    /// only an upper bound — on a pool whose weights are far apart, a permitted input can still
279    /// buy more of the output reserve than the same ratio allows. Inverting the output bound in
280    /// closed form would mean recomputing the pool's time-interpolated weights here, which would
281    /// drift from the quotes `balancer_maths_rust` produces; probing the real swap keeps the limit
282    /// consistent with [`Self::get_amount_out`] by construction. The predicate is monotonic in the
283    /// input, so the search converges on the largest accepted amount, or zero if there is none.
284    fn quantamm_max_swap_amount_in(
285        &self,
286        index_in: usize,
287        index_out: usize,
288        max_trade_size_ratio: &U256,
289    ) -> Result<U256, SimulationError> {
290        let base = self.state.base();
291        let maths_error = |e: PoolError| {
292            SimulationError::FatalError(format!(
293                "balancer_v3 swap limit failed for pool {}: {e:?}",
294                self.pool_address
295            ))
296        };
297
298        let input_cap_scaled_18 =
299            mul_down_fixed(&base.balances_live_scaled_18[index_in], max_trade_size_ratio)
300                .map_err(maths_error)?;
301        let mut high = to_raw_undo_rate_round_down(
302            &input_cap_scaled_18,
303            &base.scaling_factors[index_in],
304            &base.token_rates[index_in],
305        )
306        .map_err(maths_error)?;
307
308        let (token_in, token_out) = (&self.tokens[index_in], &self.tokens[index_out]);
309        let accepted = |amount: &U256| {
310            self.vault_swap_exact_in(*amount, token_in, token_out)
311                .is_ok()
312        };
313        if accepted(&high) {
314            return Ok(high);
315        }
316
317        let mut low = U256::ZERO;
318        while high - low > U256::from(1) {
319            let mid = low + ((high - low) >> 1);
320            if accepted(&mid) {
321                low = mid;
322            } else {
323                high = mid;
324            }
325        }
326        Ok(low)
327    }
328
329    /// Caps a weighted-pool exact-in swap in scaled-18 terms, the way the Vault would reject it
330    /// on-chain.
331    ///
332    /// Every generation enforces [`MAX_IN_RATIO`] inside `WeightedMath.computeOutGivenExactIn`.
333    /// Pools registering a per-token minimum balance (`MinTokenBalanceLib`, added to the v2
334    /// generation and not modelled by `balancer_maths_rust`) also require that neither balance
335    /// fall below its minimum after the swap, inverted here through
336    /// [`weighted_in_given_exact_out_unguarded`]. The tighter of the two wins.
337    fn weighted_max_swap_amount_in(
338        &self,
339        index_in: usize,
340        index_out: usize,
341        weights: &[U256],
342    ) -> Result<U256, SimulationError> {
343        let base = self.state.base();
344        let balances = &base.balances_live_scaled_18;
345        let maths_error = |e: PoolError| {
346            SimulationError::FatalError(format!(
347                "balancer_v3 swap limit failed for pool {}: {e:?}",
348                self.pool_address
349            ))
350        };
351
352        let ratio_cap = mul_down_fixed(&balances[index_in], &MAX_IN_RATIO).map_err(maths_error)?;
353        let (Some(&min_in), Some(&min_out)) =
354            (self.min_token_balances.get(index_in), self.min_token_balances.get(index_out))
355        else {
356            // No factory-registered minimum for this pool: nothing beyond MAX_IN_RATIO applies.
357            return Ok(ratio_cap);
358        };
359
360        // Mirrors `onSwap`'s check on the input side: it reads the current balance (offset by the
361        // Vault's rounding buffer of 1), not a post-swap one, so no amount can make this pass.
362        if balances[index_in] + U256::from(1) < min_in {
363            return Ok(U256::ZERO);
364        }
365        // A zero minimum registers no real floor for this token: skip the inversion below rather
366        // than feed it a target of the full balance, which is a singular point on the curve
367        // (`weighted_in_given_exact_out_unguarded` divides by `balance_out - target_out`).
368        if min_out.is_zero() {
369            return Ok(ratio_cap);
370        }
371        let Some(target_out) = balances[index_out].checked_sub(min_out) else {
372            return Ok(U256::ZERO);
373        };
374        if target_out.is_zero() {
375            return Ok(U256::ZERO);
376        }
377
378        let min_balance_cap = match weighted_in_given_exact_out_unguarded(
379            &balances[index_in],
380            &weights[index_in],
381            &balances[index_out],
382            &weights[index_out],
383            &target_out,
384        ) {
385            Ok(cap) => cap,
386            // The inversion raises the output reserve's depletion ratio to `weight_out /
387            // weight_in`, which is 99 on a 99/1 pool. Overflowing it means buying the output side
388            // down to its floor would take more input than a `U256` holds, so the minimum sits
389            // far beyond `MAX_IN_RATIO` and cannot be what binds.
390            Err(PoolError::MathOverflow) => return Ok(ratio_cap),
391            Err(e) => return Err(maths_error(e)),
392        };
393        Ok(ratio_cap.min(min_balance_cap))
394    }
395
396    /// Largest exact-in input a stable-pool swap can take without the live balances drifting past
397    /// [`STABLE_MAX_IMBALANCE_RATIO`]. That check has no closed-form inverse over the stable
398    /// invariant, but [`Self::stable_swap_keeps_balance_valid`] is monotonic in the input, so the
399    /// binary search converges to within a wei.
400    pub(super) fn stable_max_swap_amount_in(
401        &self,
402        index_in: usize,
403        index_out: usize,
404    ) -> Result<U256, SimulationError> {
405        let balances = &self
406            .state
407            .base()
408            .balances_live_scaled_18;
409        let mut low = U256::ZERO;
410        let mut high = MAX_VAULT_BALANCE.saturating_sub(balances[index_in]);
411        if self.stable_swap_keeps_balance_valid(index_in, index_out, &high)? {
412            return Ok(high);
413        }
414        while high - low > U256::from(1) {
415            let mid = low + ((high - low) >> 1);
416            if self.stable_swap_keeps_balance_valid(index_in, index_out, &mid)? {
417                low = mid;
418            } else {
419                high = mid;
420            }
421        }
422        Ok(low)
423    }
424
425    /// Whether an exact-in swap of `amount_in_scaled_18` (pre-fee, matching the Vault's
426    /// `amountGivenScaled18` before the swap-fee deduction) leaves every live balance inside the
427    /// pool's maximum imbalance ratio. Mirrors the v3-generation `StablePool.onSwap`, which
428    /// `balancer_maths_rust` — modelling only the December 2024 genesis contracts — does not
429    /// check.
430    pub(super) fn stable_swap_keeps_balance_valid(
431        &self,
432        index_in: usize,
433        index_out: usize,
434        amount_in_scaled_18: &U256,
435    ) -> Result<bool, SimulationError> {
436        let base = self.state.base();
437        let PoolState::Stable(state) = &self.state else {
438            return Err(SimulationError::FatalError(format!(
439                "balancer_v3 pool {} is not a stable pool",
440                self.pool_address
441            )));
442        };
443        let balances = &base.balances_live_scaled_18;
444        let maths_error = |e: PoolError| {
445            SimulationError::FatalError(format!(
446                "balancer_v3 stable limit probe failed for pool {}: {e:?}",
447                self.pool_address
448            ))
449        };
450
451        // `stable_math::compute_invariant` divides by each balance unchecked, so a zero balance on
452        // an untouched token would panic rather than error — reachable in a pool with more than
453        // two tokens, since `get_limits` only screens `index_in` and `index_out`.
454        if balances.iter().any(U256::is_zero) {
455            return Ok(false);
456        }
457
458        let fee_scaled = mul_up_fixed(amount_in_scaled_18, &base.swap_fee).map_err(maths_error)?;
459        let Some(amount_in_after_fee) = amount_in_scaled_18.checked_sub(fee_scaled) else {
460            return Ok(false);
461        };
462        if amount_in_after_fee < MINIMUM_TRADE_AMOUNT {
463            return Ok(false);
464        }
465
466        let amp = &state.mutable.amp;
467        let invariant = stable::compute_invariant(amp, balances).map_err(maths_error)?;
468        let Ok(amount_out_scaled) = stable::compute_out_given_exact_in(
469            amp,
470            balances,
471            index_in,
472            index_out,
473            &amount_in_after_fee,
474            &invariant,
475        ) else {
476            return Ok(false);
477        };
478        let Some(new_balance_out) = balances[index_out].checked_sub(amount_out_scaled) else {
479            return Ok(false);
480        };
481        let new_balance_in = balances[index_in] + amount_in_after_fee;
482
483        let min_balance = balances
484            .iter()
485            .copied()
486            .min()
487            .unwrap_or_default()
488            .min(new_balance_out);
489        let max_balance = balances
490            .iter()
491            .copied()
492            .max()
493            .unwrap_or_default()
494            .max(new_balance_in);
495        if min_balance.is_zero() {
496            return Ok(false);
497        }
498        Ok(max_balance < STABLE_MAX_IMBALANCE_RATIO * min_balance)
499    }
500
501    /// Applies a completed swap to the live balances, mirroring what the Vault does on-chain.
502    ///
503    /// The protocol's share of the swap fee leaves the pool, so it is deducted from the input
504    /// increment. Re-scaling `amount_out` from the raw amount can land one unit below the value
505    /// the Vault used, which only matters for a route crossing the same pool twice.
506    fn with_swap_applied(
507        &self,
508        amount_in: U256,
509        amount_out: U256,
510        index_in: usize,
511        index_out: usize,
512    ) -> Result<Self, SimulationError> {
513        let base = self.state.base();
514        let maths_error = |e: balancer_maths_rust::PoolError| {
515            SimulationError::FatalError(format!("balancer_v3 balance update failed: {e:?}"))
516        };
517
518        let amount_in_scaled = to_scaled_18_apply_rate_round_down(
519            &amount_in,
520            &base.scaling_factors[index_in],
521            &base.token_rates[index_in],
522        )
523        .map_err(maths_error)?;
524        let amount_out_scaled = to_scaled_18_apply_rate_round_down(
525            &amount_out,
526            &base.scaling_factors[index_out],
527            &base.token_rates[index_out],
528        )
529        .map_err(maths_error)?;
530        let total_fee_scaled =
531            mul_up_fixed(&amount_in_scaled, &base.swap_fee).map_err(maths_error)?;
532        // This returns the fee in the input token's raw units, truncated to whole ones, so it has
533        // to be scaled back up before meeting balances held at 18 decimals. Skipping that leaves
534        // the deduction short by the token's scaling factor — 10^12 for something like USDC.
535        let protocol_fee_raw = compute_and_charge_aggregate_swap_fees_raw(
536            &total_fee_scaled,
537            &base.aggregate_swap_fee,
538            &base.scaling_factors,
539            &base.token_rates,
540            index_in,
541        )
542        .map_err(maths_error)?;
543        let protocol_fee_scaled = to_scaled_18_apply_rate_round_down(
544            &protocol_fee_raw,
545            &base.scaling_factors[index_in],
546            &base.token_rates[index_in],
547        )
548        .map_err(maths_error)?;
549
550        let mut balances = base.balances_live_scaled_18.clone();
551        balances[index_in] += amount_in_scaled - protocol_fee_scaled;
552        balances[index_out] = balances[index_out].saturating_sub(amount_out_scaled);
553
554        let mut updated = self.clone();
555        updated.set_balances(balances);
556        Ok(updated)
557    }
558
559    fn set_balances(&mut self, balances: Vec<U256>) {
560        match &mut self.state {
561            PoolState::Weighted(state) => state.base.balances_live_scaled_18 = balances,
562            PoolState::Stable(state) => state.base.balances_live_scaled_18 = balances,
563            PoolState::ReClammV2(state) => state.base.balances_live_scaled_18 = balances,
564            PoolState::QuantAmm(state) => state.base.balances_live_scaled_18 = balances,
565            _ => {}
566        }
567    }
568}
569
570#[typetag::serde]
571impl ProtocolSim for BalancerV3State {
572    fn fee(&self) -> f64 {
573        u256_to_f64(self.state.base().swap_fee)
574            .map(|fee| fee / WAD)
575            .unwrap_or(0.0)
576    }
577
578    fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
579        let index_in = self.token_index(&base.address)?;
580        let index_out = self.token_index(&quote.address)?;
581        let pool_base = self.state.base();
582        let balances = &pool_base.balances_live_scaled_18;
583
584        // Probe a negligible fraction of the pool so the result approximates the marginal price.
585        // `on_swap` is called before the Vault takes the swap fee, so the ratio is pre-fee and the
586        // house-wide fee markup can be applied on top.
587        let probe = (balances[index_in] / U256::from(SPOT_PRICE_PROBE_DIVISOR)).max(U256::from(1));
588        let probe_failed = |e: PoolError| {
589            SimulationError::RecoverableError(format!(
590                "balancer_v3 spot price probe failed for pool {}: {e:?}",
591                self.pool_address
592            ))
593        };
594        let out = self
595            .pool_impl()
596            .map_err(probe_failed)?
597            .on_swap(&SwapParams {
598                swap_kind: SwapKind::GivenIn,
599                token_in_index: index_in,
600                token_out_index: index_out,
601                amount_scaled_18: probe,
602                balances_live_scaled_18: balances.clone(),
603            })
604            .map_err(probe_failed)?;
605
606        // Live balances are already normalized to 18 decimals, so the decimal correction cancels;
607        // what remains is undoing the token rates that scaled them into underlying-value terms.
608        let ratio = u256_to_f64(out)? / u256_to_f64(probe)?;
609        let rate_in = u256_to_f64(pool_base.token_rates[index_in])?;
610        let rate_out = u256_to_f64(pool_base.token_rates[index_out])?;
611        if rate_out == 0.0 {
612            return Err(SimulationError::RecoverableError(format!(
613                "balancer_v3 pool {} reports a zero rate for {}",
614                self.pool_address, quote.address
615            )));
616        }
617        Ok(add_fee_markup(ratio * rate_in / rate_out, self.fee()))
618    }
619
620    fn get_amount_out(
621        &self,
622        amount_in: BigUint,
623        token_in: &Token,
624        token_out: &Token,
625    ) -> Result<GetAmountOutResult, SimulationError> {
626        let index_in = self.token_index(&token_in.address)?;
627        let index_out = self.token_index(&token_out.address)?;
628        let amount_in = biguint_to_u256(&amount_in);
629        let amount_out = self
630            .vault_swap_exact_in(amount_in, &token_in.address, &token_out.address)
631            .map_err(|e| {
632                SimulationError::RecoverableError(format!(
633                    "balancer_v3 swap failed for pool {}: {e:?}",
634                    self.pool_address
635                ))
636            })?;
637        let new_state = self.with_swap_applied(amount_in, amount_out, index_in, index_out)?;
638
639        Ok(GetAmountOutResult::new(
640            u256_to_biguint(amount_out),
641            SWAP_GAS
642                .to_biguint()
643                .expect("u64 fits in BigUint"),
644            Box::new(new_state),
645        ))
646    }
647
648    fn get_limits(
649        &self,
650        sell_token: Bytes,
651        buy_token: Bytes,
652    ) -> Result<(BigUint, BigUint), SimulationError> {
653        let index_in = self.token_index(&sell_token)?;
654        let index_out = self.token_index(&buy_token)?;
655        let base = self.state.base();
656        if base.balances_live_scaled_18[index_in].is_zero() ||
657            base.balances_live_scaled_18[index_out].is_zero()
658        {
659            return Ok((BigUint::ZERO, BigUint::ZERO));
660        }
661
662        let max_in = self.max_swap_amount_in(index_in, index_out)?;
663        if max_in.is_zero() {
664            return Ok((BigUint::ZERO, BigUint::ZERO));
665        }
666        // A pool so close to empty that even its own largest swap trades below the Vault's
667        // minimum is a dust pool with nothing to quote, not a fatal error.
668        let max_out = match self.vault_swap_exact_in(max_in, &sell_token, &buy_token) {
669            Ok(amount_out) => amount_out,
670            Err(PoolError::TradeAmountTooSmall) => return Ok((BigUint::ZERO, BigUint::ZERO)),
671            Err(e) => {
672                return Err(SimulationError::RecoverableError(format!(
673                    "balancer_v3 swap failed for pool {}: {e:?}",
674                    self.pool_address
675                )))
676            }
677        };
678        Ok((u256_to_biguint(max_in), u256_to_biguint(max_out)))
679    }
680
681    fn delta_transition(
682        &mut self,
683        delta: ProtocolStateDelta,
684        _tokens: &std::collections::HashMap<Bytes, Token>,
685        _balances: &Balances,
686    ) -> Result<(), TransitionError> {
687        // The decoder attaches the current block's timestamp to every delta. reCLAMM quotes move
688        // with it, so take it when present and keep the previous one otherwise.
689        if let Some(timestamp) = delta
690            .updated_attributes
691            .get(BLOCK_TIMESTAMP_ATTRIBUTE)
692            .and_then(|raw| raw.as_ref().try_into().ok())
693            .map(u64::from_be_bytes)
694        {
695            self.block_timestamp = timestamp;
696        }
697
698        let engine = create_engine(SHARED_TYCHO_DB.clone(), false).expect("Infallible");
699        let pool = AlloyAddress::from_slice(self.pool_address.as_ref());
700        self.state = vm::refresh_pool_state(&engine, &pool, &self.state, self.block_timestamp)
701            .map_err(TransitionError::SimulationError)?;
702        Ok(())
703    }
704
705    fn clone_box(&self) -> Box<dyn ProtocolSim> {
706        Box::new(self.clone())
707    }
708
709    fn as_any(&self) -> &dyn Any {
710        self
711    }
712
713    fn as_any_mut(&mut self) -> &mut dyn Any {
714        self
715    }
716
717    fn eq(&self, other: &dyn ProtocolSim) -> bool {
718        other
719            .as_any()
720            .downcast_ref::<Self>()
721            .is_some_and(|other| self == other)
722    }
723}
724
725/// `WeightedMath.computeInGivenExactOut` without its own `MAX_OUT_RATIO` guard.
726///
727/// That guard bounds real exact-out swaps; here the formula only inverts `computeOutGivenExactIn`
728/// to find the input a min-balance-derived output ceiling implies. A heavily skewed pool can
729/// legitimately move more than 30% of the output reserve on an exact-in swap.
730fn weighted_in_given_exact_out_unguarded(
731    balance_in: &U256,
732    weight_in: &U256,
733    balance_out: &U256,
734    weight_out: &U256,
735    amount_out: &U256,
736) -> Result<U256, PoolError> {
737    let base = div_up_fixed(balance_out, &(balance_out - amount_out))?;
738    let exponent = div_up_fixed(weight_out, weight_in)?;
739    let power = pow_up_fixed(&base, &exponent)?;
740    let ratio = power - ONE_WAD_SCALED_18;
741    mul_up_fixed(balance_in, &ratio)
742}
743
744/// Converts a live scaled-18 balance back into the token's raw units.
745#[cfg(test)]
746fn raw_balance(
747    base: &balancer_maths_rust::common::types::BasePoolState,
748    index: usize,
749) -> Result<U256, SimulationError> {
750    to_raw_undo_rate_round_down(
751        &base.balances_live_scaled_18[index],
752        &base.scaling_factors[index],
753        &base.token_rates[index],
754    )
755    .map_err(|e| SimulationError::FatalError(format!("balancer_v3 balance rescale failed: {e:?}")))
756}