The Invariant Off by a Hundred — Python Bug Hunt
Modelled on the Uranium Finance exploit (April 2021): Uranium, a fork of Uniswap v2, changed the scale of its swap-fee arithmetic from 1,000 to 10,000, but…
- Language: Python
- Layer: Backend
- Difficulty: Medium
- Concepts: Money, Validation
- Modelled on: Uranium Finance · 2021
- Visible tests: a swap at the quoted price is accepted; a draining swap is rejected
- Reward: 50 XP for a complete fix
Briefing
Modelled on the Uranium Finance exploit (April 2021): Uranium, a fork of Uniswap v2, changed the scale of its swap-fee arithmetic from 1,000 to 10,000, but the constant-product check at the end of swap still compared against the reserves scaled by 1,000². The invariant was therefore about 100 times weaker than intended, and attackers drained the pools by asking for far more output than their input paid for — tens of millions of dollars' worth.
This project is a reconstruction. fees.py defines the fee scale; pair.py scales balances by it and then checks the product against the old constant.
Fix swap so the invariant uses the same scale as the balances.
Bug report
BUG-URANIUM-0428 · Priority: Critical · Reported by: protocol monitoring
swap(reserve0, reserve1, amount0_in, amount1_out) (token0 in, token1 out):
- raise ValueError if amount0_in <= 0 or amount1_out <= 0
- raise ValueError if amount1_out >= reserve1
- balance0 = reserve0 + amount0_in, balance1 = reserve1 - amount1_out
- adjusted0 = balance0 SCALE - amount0_in FEE, adjusted1 = balance1 * SCALE
- raise ValueError("K") unless adjusted0 adjusted1 >= reserve0 reserve1 * SCALE**2
- otherwise return [balance0, balance1]
With this rule, fees.get_amount_out(...) is exactly the largest accepted output: the quote passes, the quote + 1 is rejected.
Observed: a 1,000-unit input withdraws 900,000 of a 1,000,000 reserve.
Logs
[pair 0x8a..] swap in0=1000 out1=900000 k_check=pass
[pair 0x8a..] reserve1 1000000 -> 100000The code as shipped
src/amm/pair.py (editable)
fees = bug_require("./fees.py")
def swap(reserve0, reserve1, amount0_in, amount1_out):
"""Token0 in, token1 out. Returns the new [reserve0, reserve1]."""
if amount0_in <= 0 or amount1_out <= 0:
raise ValueError("INSUFFICIENT_AMOUNT")
if amount1_out >= reserve1:
raise ValueError("INSUFFICIENT_LIQUIDITY")
balance0 = reserve0 + amount0_in
balance1 = reserve1 - amount1_out
adjusted0 = balance0 * fees.SCALE - amount0_in * fees.FEE
adjusted1 = balance1 * fees.SCALE
if adjusted0 * adjusted1 < reserve0 * reserve1 * 1000 ** 2:
raise ValueError("K")
return [balance0, balance1]
Read-only context: src/amm/fees.py.
Open the hunt to edit the files, run the visible tests and submit against the hidden ones. More Python bug hunts.