Uniswap v2 Core Invariants
Smart contract security audit by TierZero · 2023-04-15
Independent research. This is an unsolicited security analysis published by TierZero — not a commissioned audit, and no certificate is issued. It reflects our own review of public code and on-chain events.
Findings summary
Independent research — not a commissioned audit. This is an educational design review of public, battle-tested code (Uniswap v2 Core, deployed May 2020). We did not audit a live deployment or client fork; we did not find or claim any undisclosed vulnerability.
What it is
Uniswap v2's core contracts (UniswapV2Pair, UniswapV2Factory) implement a constant-product automated market maker: x * y = k, where x and y are the reserves of two ERC-20 tokens in a pair contract. Every swap, mint, and burn must leave the product of reserves unchanged or increased, after accounting for the 0.30% fee taken on input. The design is deliberately minimal — no governance-controlled parameters in the pair itself, no oracle keeper, no external price feed. The pair contract is the invariant, the fee switch, and (as of v2) the price oracle, all in roughly 250 lines of Solidity.
Three mechanisms carry the whole system: the k check on every state-changing call, a reentrancy lock, and cumulative price accumulators for TWAP. This review covers why those three hold up, not whether a specific fork or integration built on top of them is safe.
Threat model
For a constant-product pair, the attacker classes that matter are:
- Invariant violation — draining reserves by finding a code path where
x*ydecreases post-fee, via rounding, flash-loan-funded manipulation, or a callback that mutates reserves mid-call. - Reentrancy — an ERC-777-style token or a malicious token with a transfer hook re-entering
swap(),mint(), orburn()before reserves are finalized, to extract value on a stale state. - Oracle manipulation — since v2 exposes
price0CumulativeLast/price1CumulativeLastfor external TWAP consumers, the threat is a single-block price spike (often flash-loan-funded) that a downstream consumer reads without adequate time-averaging. - Token non-conformance — fee-on-transfer tokens, tokens that return false instead of reverting, or tokens with more than 18 decimals silently breaking the pair's accounting assumptions.
Uniswap v2 was not designed to defend against the fourth category; it assumes standard ERC-20 semantics and documents that non-standard tokens are the integrator's problem.
Why the design holds (key invariants & mitigations)
The k check. swap() computes new reserves and requires the fee-adjusted product to be at least the old product:
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"
);
This check happens after optimistic transfers out and the callback (uniswapV2Call), which is what makes flash swaps safe: whatever the caller does with the borrowed tokens, the trade only finalizes if the invariant holds when the dust settles. There's no separate "flash loan" code path — it's the same swap function, and k is the only gate that matters.
The reentrancy lock. A simple lock modifier using a storage-backed boolean guards mint, burn, swap, and sync. It's unfashionable compared to OpenZeppelin's ReentrancyGuard, but functionally identical — one SSTORE-based mutex, checked and set before external calls, cleared after. Because the lock wraps every state-mutating public entry point, a malicious token can't reenter the pair mid-swap even if its transfer() calls back into the caller.
Cumulative-price TWAP. Rather than storing a spot price, the pair accumulates price * timeElapsed into a monotonically increasing uint224 on every block where reserves first change (_update(), called at most once per block via the blockTimestampLast check). A consumer takes two snapshots of the cumulative value and divides the delta by the elapsed time to get a time-weighted average. This makes manipulation cost proportional to the number of blocks the attacker controls, not the size of a single trade — moving the TWAP meaningfully requires sustained control over consecutive blocks, which is a materially different (and much more expensive) attack than a single flash-loan spot-price spike against a naive getReserves()-based consumer.
Where implementers get it wrong
The pair contract has held up for years; the failures cluster in how people use it, which is the pattern we see repeatedly in our smart contract audit engagements:
- Reading spot reserves as price. Calling
getReserves()and dividing is not an oracle. Every major DeFi exploit involving a "Uniswap oracle manipulation" was actually a protocol reading instantaneous reserves, not the TWAP accumulator, inside a single transaction alongside a flash loan. - Ignoring first-depositor and donation griefing. Directly transferring tokens to a pair before the first
mint()inflateskwithout minting LP shares proportionally, a variant of the ERC-4626 inflation problem worth checking in any fork or vault wrapping v2 pairs. - Assuming 1:1 token accounting. Fee-on-transfer and rebasing tokens break the assumption that
amountIntransferred equalsamountInreceived; pairs holding such tokens withoutskim()/sync()discipline drift from their nominal reserves. - Underestimating callback surface in forks. Teams that fork v2 and add hooks (custom fee logic, whitelisting, pausability) frequently introduce reentrancy or invariant-bypass bugs that the original minimal design avoided by having almost no surface area — the kind of regression a structured code review and audit process is built to catch before deployment.
These are the same class of composability and boundary-trust issues we cover for non-EVM stacks too — see our notes on cross-program invocation risk in Solana DeFi for the analogous problem in a different execution model.
Takeaways
Uniswap v2's security holds because the design surface is small: one invariant, one lock, one accumulator, no admin keys in the pair itself. That minimalism is the control. Every documented incident attributable to "Uniswap v2" in the wild is really a downstream integration failing to respect the TWAP's time-averaging property or assuming a non-standard token behaves like a standard one. If you're building on top of v2-style pairs, or forking the core with modifications, that's exactly the boundary where a second set of eyes earns its keep — talk to us about a smart contract audit.
Need your contracts audited?
Manual review + tooling across TON, Solana and EVM — certificate and full report included.
Get an audit