Uranium Finance v2 K-Invariant Scaling-Mismatch Drain (2021-04-28)

  • Name: Uranium Finance v2 K-invariant scaling-mismatch drain
  • Date: 2021-04-28
  • Protocol: Uranium Finance
  • Chain: BNB Chain
  • Category: AMM invariant scaling mismatch / constant-product guard failure / fork-modification accounting bug
  • Loss: commonly reported as about 53.7M drained across 26 pools
  • Summary: Uranium Finance’s v2 AMM fork changed Uniswap v2’s swap-fee scaling from 1000-based math to 10000-based math, but left the final invariant check comparing against reserve0 * reserve1 * 1000**2. That one-line denominator mismatch was catastrophic. It made the pair’s post-swap balanceAdjusted product appear roughly 100x larger than the threshold it had to satisfy, so the attacker could send a trivial amount of one asset into a pair and ask swap() for almost all of the other reserve without violating the contract’s broken K check. The attacker repeated that pattern across 26 Uranium pools and walked away with tens of millions in BUSD, BNB, BTCB, ETH, USDT, DOT, ADA, and other assets.
  • Trigger / entry point:
    • Uranium Finance forked Uniswap v2 pair logic and modified fee-related arithmetic in swap().
    • The pair contract multiplied balance0 and balance1 by 10000 and subtracted amountIn * 16, but still enforced the final invariant against reserve0 * reserve1 * 1000**2.
    • Because the left-hand side of the check was scaled up by 10000 while the right-hand side still assumed 1000, the contract accepted economically impossible post-swap states.
    • The attacker only needed ordinary access to the public pair swap() path; no governance, privileged key, or oracle manipulation was required.
  • Root cause:
    • Uranium changed one side of the AMM invariant’s unit system without changing the other side to match.
    • In canonical Uniswap v2, the swap guard keeps both sides in the same fee base:
      uint balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3));
      uint balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3));
      require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(1000**2), 'UniswapV2: K');
    • Uranium changed the adjusted-balance side to 10000-based math but left the threshold at 1000**2:
      uint balance0Adjusted = balance0.mul(10000).sub(amount0In.mul(16));
      uint balance1Adjusted = balance1.mul(10000).sub(amount1In.mul(16));
      require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(1000**2), 'UraniumSwap: K');
    • That desynchronized the accounting guard from the values it was supposed to constrain. Before even considering the fee subtraction, the left-hand side now carried 10000 * 10000 scaling while the right-hand side still assumed 1000 * 1000, so the check inherited about 100x fake slack.
    • In practice, the pair contract no longer had a meaningful proof that post-swap reserves preserved the intended constant-product economics.
  • Exploit path:
    1. The attacker deployed or used attack contract 0x2b528a28451e9853f51616f3b0f6d82af8bea6ae.
    2. In exploit transaction 0x5a504fe72ef7fc76dfeb4d979e533af4e23fe37e90b5516186d5787893c37991, the attacker targeted Uranium v2 pairs on BNB Chain.
    3. For a chosen pair, the attacker first transferred a small quantity of token A directly into the pair contract.
    4. The attacker then called swap() requesting almost all of token B sitting in the pool.
    5. Because Uranium’s balanceAdjusted terms were multiplied by 10000 but the invariant threshold still used 1000**2, the pair accepted this wildly imbalanced state as if the constant-product invariant had been preserved.
    6. After draining one side of a pool, the attacker could use some of the newly stolen asset to perform the reverse trade and strip most of the other side as well.
    7. The same primitive was repeated across 26 pools, draining large balances of major assets and stablecoins.
  • What the attacker needed to be true:
    • Uranium’s live pairs had to run the mismatched swap() invariant code rather than canonical Uniswap v2 math.
    • Pool reserves had to be deep enough that draining nearly all of one side was economically meaningful.
    • The attacker only needed a small amount of starter capital because the broken invariant let the pool’s own reserves finance the rest of the extraction.
    • No other guardrail could exist downstream to sanity-check post-swap reserve ratios or cap single-transaction outflows.
  • Technical anatomy worth remembering:
    • This was not an oracle failure or a flash-loan trick. It was a direct accounting bug in the AMM’s core solvency check.
    • The high-signal lesson is not merely one character broke everything; it is that AMM forks are brittle when developers change fee constants, precision scales, or invariant terms in only part of the formula.
    • Uranium’s pair logic effectively made swap() believe a nearly empty reserve state still satisfied the same invariant Uniswap uses to stop exactly that outcome.
    • Public reproductions of the exploit are useful because they show how little capital the broken guard actually required: on a pre-exploit fork around BNB Chain block 6,920,000, sending 1 WBNB into the WBNB/BUSD pair was enough to request about 99% of the pool’s BUSD reserve, and then roughly $1 of that stolen BUSD could be sent back in to drain about 99% of the WBNB side as well.
    • Because the bug lived in the central pair primitive, the exploit generalized cleanly across many markets instead of being isolated to one token or one edge-case route.
    • The exploit also shows why battle-tested AMM code is dangerous to customize casually: even tiny denominator drift can silently turn reserve accounting into a withdraw-anything primitive.
  • Representative transactions and addresses:
    • Main exploit transaction: 0x5a504fe72ef7fc76dfeb4d979e533af4e23fe37e90b5516186d5787893c37991
    • Attack contract highlighted by public reproductions: 0x2b528a28451e9853f51616f3b0f6d82af8bea6ae
    • Uranium Factory address used in public PoCs: 0xA943eA143cd7E79806d670f4a7cf08F8922a454F
    • One high-value target pool in public PoCs: Uranium WBNB/BUSD pair derived from the factory at pre-exploit state around BNB Chain block 6,920,000
  • Response / mitigation:
    • Uranium Finance acknowledged the exploit and halted the protocol, but the damage was already done because the vulnerable pair logic itself had been live across production pools.
    • Public analysis after the incident focused on the modified pair math and the danger of lightly reviewed AMM forks.
    • Later law-enforcement reporting said roughly $31M connected to the Uranium exploits was seized in 2025, underscoring that even apparently finished DeFi drains can remain investigable long after the protocol dies.

Pattern tags

  • Local arithmetic object promoted into canonical solvency truth: Uranium still had a K-looking check, but the computed balanceAdjusted product no longer measured the same unit system as the reserve threshold it was supposed to certify.
  • Partial fork edit broke a shared proof obligation: the team changed the fee-base constants on the adjusted-balance side of swap() without moving every sibling literal participating in the same invariant proof.
  • Tiny-input reserve-authority collapse: once the guard inherited about 100x false slack, a trivially small transfer could authorize nearly the full opposite reserve because the pair treated an incoherent post-swap state as valid.
  • Bytecode-family replay surface: the bug was not pool-specific; one broken pair implementation meant every market sharing that code path exposed the same drain primitive.

Why the exploit worked

  • Uranium did not merely have bad fee math. It had a deeper promotion failure: some balance-adjusted product exists, that product still counts as canonical pool-solvency truth, and that accepted truth still deserves live reserve-release authority were treated as the same claim even after the unit system had already split.
  • The critical asymmetry was dimensional, not market-based. The attacker did not need special timing, oracle control, or complex routing. Once the adjusted-balance side moved to 10000-based math while the threshold stayed at 1000**2, the pair itself started certifying impossible reserve states as if they preserved the intended constant-product economics.
  • That meant the protocol’s deepest defense line had become the exploit rail. The final require(... K ...) was supposed to stop exactly tiny input, huge output swaps, but instead it granted about 100x fake slack before real fee economics even entered, so the pool’s own solvency proof authorized the drain.
  • The primitive also generalized cleanly because the flaw lived in shared pair bytecode rather than in one market configuration. Once one pool proved drainable, every sibling pool with meaningful reserves inherited the same small seed capital -> near-total reserve withdrawal path.
  • In practical terms, Uranium confused the contract computed a solvency-looking number with the contract proved this post-swap state is solvent. The exploit worked because those were no longer the same statement after the fork edits diverged.

Detection and audit pivots

  • Build a changed-constants ledger for every forked AMM pair and trace each edited fee base, precision multiplier, and denominator through the whole swap() proof. Uranium’s loss was not hidden in a novel edge case; it was visible in the fact that 10000-based adjusted balances were still being judged against a 1000**2 threshold.
  • Run a no-economic-change dimensional check before fuzzing anything exotic. If reserves stay effectively unchanged, ask whether the final invariant guard has suddenly become easier to satisfy purely because one side was rescaled. Uranium would have failed that review instantly because the guard inherited about 100x fake slack before any real fee economics entered.
  • Differential-test forked pair math against the upstream parent on the exact same reserve states, including tiny-input / extreme-amountOut attempts. The high-signal adversarial question is simple: can 1 unit or a similarly trivial input now buy nearly the full opposite reserve when canonical Uniswap-style math would reject it?
  • Treat public pair bytecode as fleet risk, not pool-local risk. Once one Uranium pair was shown drainable, every market sharing the same implementation should have been assumed compromised; reviewers and operators should keep an explicit bytecode family -> live pools map for exactly this class of incident.
  • Audit helper paths and monitors for reserve impossibilities rather than only transaction failures. Near-total one-sided reserve depletion after a tiny input, reserve products collapsing far below modeled bounds, or swaps accepting outputs that are obviously inconsistent with recent reserve state are all stronger signals than generic large trade alerts.
  • Re-derive the guard after every fork customization instead of trusting inherited Uniswap safety by brand association. The dangerous review failure in Uranium was not lack of source access; it was assuming that locally plausible edited lines still formed the same solvency proof once the constants diverged.

Why this incident matters

  • It is one of the cleanest examples of a fork-level AMM accounting edit turning a core invariant check into fiction.
  • It demonstrates that battle-tested fork + tiny local customization can be a more dangerous pattern than greenfield code, because teams inherit the aura of Uniswap’s safety while subtly breaking the assumptions that made it safe.
  • It is also a useful counterexample to exploit narratives that overemphasize flash loans or exotic composability. Sometimes the primitive itself will simply hand out reserves if its unit system drifts.

Three invariant-authority promotions this incident collapsed

  1. The pair computed a K-looking post-swap arithmetic object

    • Uranium still produced balance0Adjusted and balance1Adjusted, multiplied them together, and routed the result into a final require(... 'UraniumSwap: K') check.
    • That only proved some arithmetic expression was evaluated. It did not yet prove the result still lived in the same unit system as the reserve product it was supposed to constrain.
  2. That arithmetic object was still promoted into canonical solvency truth for the pair

    • The fork changed the adjusted-balance side of the proof to 10000-based math and amountIn * 16 fee subtraction, but left the threshold at reserve0 * reserve1 * 1000**2.
    • So the contract kept treating the check passed as equivalent to the intended constant-product invariant still holds, even though the proof had already split into mismatched domains and inherited roughly 100x fake slack.
    • The healthy review boundary Uranium lost was simple: this expression returned true should not automatically have meant this pair is solvent under the economics we think we are enforcing once the fork changed the constants.
  3. That accepted solvency story still inherited immediate reserve-release authority

    • After the mismatched check blessed an impossible post-swap state, swap() still released real BUSD, BNB, BTCB, ETH, USDT, DOT, ADA, and other assets as though the pool had remained within a valid invariant envelope.
    • The catastrophic step was not just the math bug. It was that a locally incoherent proof object still carried full withdrawal authority over nearly the entire opposite reserve.

The durable lesson is that the pair computed a solvency-looking number, that number still counts as canonical proof that reserves remain inside the intended invariant, and that accepted proof may now authorize reserve release are three different promotions. Uranium collapsed all three into one broken K check.

Failure-prevention checklist

  • Re-derive the full post-swap invariant before changing any fee base, multiplier, precision constant, or reserve-normalization rule in forked pool math.
  • Keep a reviewer-visible inventory of every changed arithmetic constant in core swap logic, and verify that each change is mirrored everywhere the same proof uses that unit system.
  • Differential-test modified pairs against the upstream AMM across the same reserves, inputs, and extreme amountOut requests whenever the fork still claims to preserve the same economic behavior.
  • Add property tests that fail if tiny inputs can extract most of the opposite reserve or if unchanged-reserve scenarios suddenly make the final guard easier to satisfy for purely dimensional reasons.
  • Treat pair math rollout as protocol-wide treasury risk. If one pair contract implementation is wrong, assume every pool using it can be drained the same way.
  • Add runtime blast-radius controls such as per-swap outflow caps, rapid pausing, and monitoring for reserve transitions the intended invariant should never allow.

Audit prompts

  • After every fee or precision edit, do both sides of the final require(... K ...) still measure the same economic quantity in the same base?
  • Which literals in swap() are duplicated proof constants rather than independent tunables, and who verified that all of them moved together?
  • What differential tests prove the fork is still economically equivalent to the upstream AMM under normal trades, edge-state trades, and reserve-depletion attempts?
  • Can a trader transfer in a trivially small amount, request nearly the full opposite reserve, and still satisfy the post-swap guard? What exact test demonstrates the answer is no?
  • If this arithmetic change shipped unnoticed, how many production pools would inherit it immediately, and what monitoring would catch impossible reserve states before they were emptied?
  • Did reviewers re-derive the invariant from first principles after the customization, or did the team rely on inherited safety from the upstream fork despite changing its constants?

Lessons that should already be standard

  • Tiny AMM math edits are balance-sheet edits. Changing one denominator in pool core can be enough to turn a solvent market into a public reserve-withdrawal primitive.
  • Forked from Uniswap stops being a security argument the moment a team changes fee bases or scaling constants without re-proving the whole swap guard.
  • The final invariant check is not a decorative sanity line. If it no longer constrains the same quantity the earlier math computed, the AMM is enforcing fiction.
  • Differential testing against the parent implementation should be standard whenever the product claim is effectively same AMM, different fee schedule.
  • Shared pair implementations make small arithmetic bugs systemic. Once one pool proves drainable, every sibling pool becomes an obvious next target.
  • Runtime loss containment matters even for core AMM math. A healthy design assumes one proof obligation can fail and still tries to keep that failure from emptying the whole fleet in one pass.

Internal linkages

Control surface

  • The leverage sat in Uranium’s forked pair swap() math, specifically the agreement that should have existed between fee-adjusted balances and the final constant-product threshold.
  • Uranium did not fail because a surrounding module priced the pair incorrectly. The pair contract itself stopped enforcing the reserve invariant that made the pool solvent.

Sources

Last reviewed

  • 2026-08-04 UTC