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 to10000-based math, but left the final invariant check comparing againstreserve0 * reserve1 * 1000**2. That one-line denominator mismatch was catastrophic. It made the pair’s post-swapbalanceAdjustedproduct 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 askswap()for almost all of the other reserve without violating the contract’s brokenKcheck. 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
balance0andbalance1by10000and subtractedamountIn * 16, but still enforced the final invariant againstreserve0 * reserve1 * 1000**2. - Because the left-hand side of the check was scaled up by
10000while the right-hand side still assumed1000, 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.
- Uranium Finance forked Uniswap v2 pair logic and modified fee-related arithmetic in
- 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 at1000**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 * 10000scaling while the right-hand side still assumed1000 * 1000, so the check inherited about100xfake slack. - In practice, the pair contract no longer had a meaningful proof that post-swap reserves preserved the intended constant-product economics.
- Exploit path:
- The attacker deployed or used attack contract
0x2b528a28451e9853f51616f3b0f6d82af8bea6ae. - In exploit transaction
0x5a504fe72ef7fc76dfeb4d979e533af4e23fe37e90b5516186d5787893c37991, the attacker targeted Uranium v2 pairs on BNB Chain. - For a chosen pair, the attacker first transferred a small quantity of token A directly into the pair contract.
- The attacker then called
swap()requesting almost all of token B sitting in the pool. - Because Uranium’s
balanceAdjustedterms were multiplied by10000but the invariant threshold still used1000**2, the pair accepted this wildly imbalanced state as if the constant-product invariant had been preserved. - 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.
- The same primitive was repeated across 26 pools, draining large balances of major assets and stablecoins.
- The attacker deployed or used attack contract
- 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.
- Uranium’s live pairs had to run the mismatched
- 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, sending1 WBNBinto the WBNB/BUSD pair was enough to request about99%of the pool’s BUSD reserve, and then roughly$1of that stolen BUSD could be sent back in to drain about99%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
- Main exploit transaction:
- 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 computedbalanceAdjustedproduct 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
100xfalse 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, andthat accepted truth still deserves live reserve-release authoritywere 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 at1000**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 exactlytiny input, huge outputswaps, but instead it granted about100xfake 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 withdrawalpath. - In practical terms, Uranium confused
the contract computed a solvency-looking numberwiththe 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 that10000-based adjusted balances were still being judged against a1000**2threshold. - 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
100xfake 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-
amountOutattempts. The high-signal adversarial question is simple: can1unit 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 poolsmap 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 tradealerts. - 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 customizationcan 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
-
The pair computed a
K-looking post-swap arithmetic object- Uranium still produced
balance0Adjustedandbalance1Adjusted, multiplied them together, and routed the result into a finalrequire(... '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.
- Uranium still produced
-
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 andamountIn * 16fee subtraction, but left the threshold atreserve0 * reserve1 * 1000**2. - So the contract kept treating
the check passedas equivalent tothe intended constant-product invariant still holds, even though the proof had already split into mismatched domains and inherited roughly100xfake slack. - The healthy review boundary Uranium lost was simple:
this expression returned trueshould not automatically have meantthis pair is solvent under the economics we think we are enforcingonce the fork changed the constants.
- The fork changed the adjusted-balance side of the proof to
-
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.
- After the mismatched check blessed an impossible post-swap state,
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
amountOutrequests 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 Uniswapstops 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
- Core recurring pattern: amm-invariant-scaling-mismatch.
- Positive-design companion: amm-invariant-unit-consistency-and-fork-equivalence-discipline — the sharper design question here is how AMM forks should keep one invariant object, one unit map, one differential-equivalence claim, and one staged rollout boundary explicit before modified pool math reaches many live markets.
- Adjacent AMM-math family: directional-rounding-bias-roundtrip-non-invariance — both families turn tiny arithmetic edits into drainable swap paths, but Uranium broke the pair’s solvency guard outright rather than introducing a profitable edge-state rounding loop.
- Useful contrast: fee-on-transfer-accounting-mismatch — both can leave pool balances looking solvent when they are not, but Uranium’s fiction came from the AMM’s own invariant math, not from hostile token transfer semantics.
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
- https://immunefi.com/blog/bug-fix-reviews/hack-analysis-uranium-finance-april-2021/
- https://raw.githubusercontent.com/SunWeb3Sec/DeFiHackLabs/main/src/test/2021-04/Uranium_exp.sol
- https://bscscan.com/tx/0x5a504fe72ef7fc76dfeb4d979e533af4e23fe37e90b5516186d5787893c37991
- https://www.trmlabs.com/resources/blog/u-s-authorities-seize-31-million-in-uranium-finance-exploits-investigation
Last reviewed
- 2026-08-04 UTC