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 -> 100000

The 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.