Same Function, Opposite Arithmetic: Uniswap V2's _update()
Inside Uniswap V2's _update(), reserve writes must revert on overflow, but the price accumulator math must allow it. Why Solidity 0.8 makes this trickier than it looks.
TL;DR
- Uniswap V2's
_update()function has two pieces of math that need exactly opposite overflow handling. - The reserve writes to
uint112MUST revert on overflow. Silent truncation makes the constant-product invariant run on wrong reserves; the pool is drainable. - The price accumulator math MUST allow overflow. The TWAP oracle only ever subtracts cumulative values; modular subtraction handles wraparound for free; reverting would brick the oracle.
- Solidity 0.8 made every arithmetic op revert by default. If you fork Uniswap V2 to a modern compiler without understanding this, you have to wrap the accumulators in
uncheckedblocks while keeping the reserve writes checked. - One function, two arithmetic regimes, one easy mistake.
Why this matters
When teams modernize Uniswap V2 forks to current Solidity compilers, this is among the most common places they get it wrong. The original Uniswap V2 was written for Solidity 0.5.x, where overflow was the default. Solidity 0.8.0 (December 2020) flipped this: arithmetic now reverts by default. Most ports either:
- Wrap everything in
unchecked { ... }blocks to preserve original behavior, which silently breaks the reserve overflow check. - Leave everything checked, which breaks the TWAP oracle the moment a timestamp wrap or large accumulator value is encountered.
Both are wrong. The correct port keeps the reserves CHECKED and wraps only the accumulator math in unchecked. Few engineers explicitly think about this when porting; the bug doesn't surface until production traffic exercises the edge cases.
If you're auditing or building a Uniswap V2 fork, the _update() function is one of the first places to look.
The function in question
Here is _update() from Uniswap V2's UniswapV2Pair.sol, lightly annotated:
function _update(
uint balance0,
uint balance1,
uint112 _reserve0,
uint112 _reserve1
) private {
require(
balance0 <= type(uint112).max && balance1 <= type(uint112).max,
"UniswapV2: OVERFLOW"
);
uint32 blockTimestamp = uint32(block.timestamp % 2**32);
uint32 timeElapsed = blockTimestamp - blockTimestampLast;
if (timeElapsed > 0 && _reserve0 != 0 && _reserve1 != 0) {
// these accumulator writes are intentionally allowed to overflow
price0CumulativeLast += uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed;
price1CumulativeLast += uint(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) * timeElapsed;
}
reserve0 = uint112(balance0);
reserve1 = uint112(balance1);
blockTimestampLast = blockTimestamp;
emit Sync(reserve0, reserve1);
}
Source: UniswapV2Pair.sol:73-86
There are three arithmetic regions in this function. Two have opposite requirements, and the third is incidental.
Region 1: The reserve overflow check (must revert)
require(
balance0 <= type(uint112).max && balance1 <= type(uint112).max,
"UniswapV2: OVERFLOW"
);
The reserves are stored as uint112 (max value approximately 5.19e33, or 5 quadrillion 18-decimal tokens). Any token whose total supply could exceed this in a pool would, without this check, get its balance silently truncated when assigned to uint112.
Why does silent truncation matter? Because the constant-product invariant x * y >= k is checked using reserve0 and reserve1, the stored values. If balance0 = 2^120 and gets truncated to balance0 mod 2^112, the swap function sees a smaller reserve than actually exists. An attacker can request a swap output proportional to the full balance while the contract validates against the truncated balance. The pool can be drained.
Result: this require statement MUST revert on overflow. Removing it, or wrapping the cast in unchecked, opens the drain.
Region 2: The price accumulator (must allow overflow)
uint32 timeElapsed = blockTimestamp - blockTimestampLast;
// ...
price0CumulativeLast += uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed;
The accumulators store the cumulative price-time product since the contract was deployed. They grow without bound. Eventually they overflow uint256, and that's fine.
Why fine? Because the TWAP oracle never reads accumulator values directly. It reads two snapshots, separated in time, and computes the average price over that window:
price_avg = (price1CumulativeLast_at_T2 - price0CumulativeLast_at_T1) / (T2 - T1)
Modular subtraction handles wraparound naturally. If the accumulator wrapped between T1 and T2, the difference still represents the correct elapsed cumulative price. As long as the wrap happens at most once per oracle window (which is ensured by uint256 being unfathomably large), the math works.
This is the same reason the timestamp uses uint32 blockTimestamp = uint32(block.timestamp % 2**32) rather than the full uint256. Wraparound is the design, not a bug to be patched.
If you keep the accumulator writes in checked-arithmetic mode under Solidity 0.8, the contract reverts the first time the accumulator approaches 2**256. Pool transactions revert. The oracle is bricked. Liquidity providers can no longer add or remove liquidity.
Result: this code MUST be wrapped in unchecked { ... } under Solidity 0.8.
Region 3: The timestamp difference
uint32 timeElapsed = blockTimestamp - blockTimestampLast;
This subtraction can also wrap (between two uint32 values, the difference may produce a different result if blockTimestampLast > blockTimestamp due to the wrap). It's intentionally modular for the same reason as the accumulators. Under 0.8, it also needs unchecked.
The correct Solidity 0.8 port
Here is _update() adapted for Solidity 0.8.x, with both arithmetic regimes expressed correctly:
function _update(
uint balance0,
uint balance1,
uint112 _reserve0,
uint112 _reserve1
) private {
require(
balance0 <= type(uint112).max && balance1 <= type(uint112).max,
"UniswapV2: OVERFLOW"
);
uint32 blockTimestamp = uint32(block.timestamp);
unchecked {
uint32 timeElapsed = blockTimestamp - blockTimestampLast;
if (timeElapsed > 0 && _reserve0 != 0 && _reserve1 != 0) {
price0CumulativeLast +=
uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed;
price1CumulativeLast +=
uint(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) * timeElapsed;
}
}
reserve0 = uint112(balance0);
reserve1 = uint112(balance1);
blockTimestampLast = blockTimestamp;
emit Sync(reserve0, reserve1);
}
The require stays outside the unchecked block. The cast uint112(balance0) is also OUTSIDE, but at this point we've already validated balance0 <= type(uint112).max, so the cast is safe; under 0.8, casts to smaller types do NOT revert by default (they truncate), so even without the require we'd get truncation rather than a revert. The require is what enforces the no-truncation contract.
The unchecked block wraps only the timestamp subtraction and the two accumulator updates. Under 0.8, this is the minimum scope that preserves Uniswap V2's intended overflow behavior without weakening the reserve check.
Common porting mistakes
Mistake 1: unchecked everywhere
function _update(...) private {
unchecked {
require(
balance0 <= type(uint112).max && balance1 <= type(uint112).max,
"UniswapV2: OVERFLOW"
);
// ... rest of function
}
}
Looks like it preserves original behavior. Doesn't, because the explicit require would still revert on the comparison the same way, BUT the cast reserve0 = uint112(balance0) inside the unchecked block would silently truncate without further protection if the require check were ever relaxed or bypassed. More importantly, this style obscures where overflow is permissible vs not. Any future modifier of the contract could remove the require thinking "we have unchecked anyway" without realizing the cast then becomes a vulnerability.
Mistake 2: Removing the require entirely
Some forks "modernize" by treating the require as redundant under Solidity 0.8 ("the cast will revert anyway"). It won't. Solidity 0.8 reverts on arithmetic overflow but truncates on explicit casts. uint112(2**150) does not revert; it returns 2**150 mod 2**112.
Mistake 3: Forgetting the timestamp unchecked
function _update(...) private {
require(...);
uint32 blockTimestamp = uint32(block.timestamp);
uint32 timeElapsed = blockTimestamp - blockTimestampLast; // BUG under 0.8
unchecked {
// accumulator updates only
}
}
The subtraction blockTimestamp - blockTimestampLast is also a candidate for wrap (every ~136 years for uint32 timestamps). If the wrap occurs OUTSIDE the unchecked block, the contract reverts. The first transaction post-wrap fails. This is exactly the bug the original uint32 choice was designed to make impossible.
How to verify in your fork
Three checks:
- Search for
_updatein your fork's pair contract. Confirm the require is present and operates onuint(notuint256oruint128casts that would defeat it). - Confirm the timestamp subtraction and accumulator increments are inside an
uncheckedblock, not outside. - Run a unit test that simulates 136 years of timestamps (or just sets
blockTimestampLastto a value near2^32 - 1andblock.timestampto a small value just after wrap). The contract should NOT revert.
When you rebuild Uniswap V2 in Zealynx Academy, the test suite includes these checks. The _update() section will fail until both arithmetic regimes are expressed correctly.
Related questions
Why is Solidity 0.8's default-revert behavior considered an improvement, even when it breaks designs like this? Default-revert prevents silent overflow bugs in the 99% of cases where overflow IS a bug. Designs like Uniswap V2's accumulators are the rare exception, and the language gives you unchecked for those.
Can I just use uint256 for reserves to avoid the truncation problem? You'd lose the storage-packing optimization that puts both reserves and the timestamp into a single 32-byte slot, costing 2100+ gas per swap. The uint112 choice is both a security and gas-optimization decision.
Why not have the accumulators saturate at 2^256 - 1 instead of wrap? Saturating arithmetic would lose the modular-subtraction property that makes TWAP work across overflow boundaries. The math relies on (a - b) mod 2^256 being equal to a - b when a > b and being a meaningful difference when a < b (because a wrapped). Saturating would break the second case.
Does Uniswap V3 have the same dual-arithmetic gotcha? No. V3's price oracle uses tick math and observation arrays rather than a single cumulative accumulator. Different design, different gotchas.
The lesson for builders
Uniswap V2 is full of choices that look like quirks until you trace the threat model and the math. The 1000-wei minimum liquidity lock, the storage packing, the dual-arithmetic _update(), the skim() and sync() donation mitigations: each is a load-bearing piece of code where understanding why it exists is more important than copying what it says.
When you rebuild Uniswap V2 line by line in Zealynx Academy, you can't skip these. The 217 automated tests fail until each piece is implemented correctly, with the right arithmetic regime, the right ordering, the right defenses against the right attack.
Tagged