Why Uniswap V2's Timestamp Wraps in 2106 (and Why It's a Feature)
Uniswap V2 packs block.timestamp into uint32, which wraps to zero on January 19, 2106. The TWAP oracle keeps working across the wrap because modular subtraction handles it for free.
TL;DR
- Uniswap V2 stores
blockTimestampLastas auint32. The maximum value is 2^32 - 1, which corresponds to Tuesday, January 19, 2106 at 03:14:07 UTC. - After that timestamp, the value wraps to zero. This is intentional, not a bug.
- The TWAP oracle never reads timestamps directly. It only ever subtracts two cumulative timestamps and uses the difference as a divisor. Modular subtraction in unsigned integer arithmetic handles wraparound correctly.
- "Fixing" the type to
uint64would break the storage packing that saves 2100 gas per swap. The "broken" code is more efficient and more correct than the more obvious alternative. - This is one of the cleanest examples of a Solidity design choice that looks wrong, is actually right, and only makes sense once you trace the math.
Why this matters
There is a class of "bugs" in Uniswap V2 that look like bugs to readers who haven't traced the math. The 1000-wei minimum liquidity lock is one. The uint32 timestamp is another. Both look like premature optimizations or oversights. Both are deliberate, mathematically justified, and present-day-correct.
If you fork Uniswap V2 and your team spots uint32 for the timestamp, the natural reaction is "this will break in 2106, let's widen it to uint64". That change costs you 2100 gas per swap and makes the oracle math harder to reason about. It also fails Solidity's storage packing in a way that may not surface until a gas regression test catches it.
Understanding why the wrap is fine, and why the original choice is the better one, is one of the better demonstrations that the Uniswap V2 authors knew exactly what they were doing.
What uint32 actually buys you
The packing math from the slot layout: 112 + 112 + 32 = 256. If you change the timestamp to uint64, you have 112 + 112 + 64 = 288 bits, which doesn't fit in a 256-bit slot. The Solidity compiler would push the timestamp into a second slot.
That second slot costs 2100 gas to read on a cold SLOAD. Every swap, mint, and burn touches the timestamp via _update(). Every one of those operations now reads two slots instead of one.
We have a separate post on the gas math: How Uniswap V2 Saves 2100 Gas Per Swap with Storage Packing. For this discussion, take it as given that the 32 bits for the timestamp are the difference between one storage slot and two.
So the question is: can the protocol get by with a timestamp that wraps every 136 years?
What the TWAP oracle actually does with timestamps
The TWAP oracle in Uniswap V2 maintains two cumulative price values, price0CumulativeLast and price1CumulativeLast. These accumulate the price weighted by elapsed time on every interaction. To compute a TWAP between any two points in time, you sample these cumulatives and the timestamps at both points, then divide the difference in cumulatives by the difference in timestamps.
The pseudocode for an external TWAP consumer is:
// at time t0
uint price0CumulativeStart = pair.price0CumulativeLast();
uint32 blockTimestampStart = pair.blockTimestampLast();
// later, at time t1
uint price0CumulativeEnd = pair.price0CumulativeLast();
uint32 blockTimestampEnd = pair.blockTimestampLast();
uint elapsed = uint(blockTimestampEnd - blockTimestampStart);
uint twapPrice = (price0CumulativeEnd - price0CumulativeStart) / elapsed;
Notice what the consumer does with the timestamps: it subtracts them. It does not compare them to block.timestamp directly. It does not use them as wall-clock time. It only uses the difference between two cumulative timestamps to figure out how much time passed.
That detail is the entire reason uint32 works.
Modular subtraction across the wrap
In Solidity, unsigned integer subtraction is modular. If you have two uint32 values and subtract one from the other, the result is computed modulo 2^32.
Consider what happens at the wrap boundary on January 19, 2106:
- At time t0 (one minute before the wrap):
blockTimestampLast = 4294967200(just below 2^32 - 1). - At time t1 (one minute after the wrap):
blockTimestampLast = 60(just past zero).
A naive subtraction would give 60 - 4294967200, which is negative. In signed math that would be a large negative number. In unsigned modular math, it wraps:
60 - 4294967200 mod 2^32
= 60 + (2^32 - 4294967200) mod 2^32
= 60 + 96 mod 2^32
= 156
Wait, that's not quite right. Let me redo it more carefully.
In Solidity, uint32(60) - uint32(4294967200) is computed as (60 - 4294967200) mod 2^32. Since 60 < 4294967200, the result before the mod is -4294967140, which in modular arithmetic is 4294967296 - 4294967140 = 156. That's the elapsed time in seconds, which is exactly two minutes plus a few seconds, which is what you expect.
The elapsed time computed via modular subtraction across the wrap is correct, as long as the elapsed time itself fits in 32 bits. Since 2^32 seconds is roughly 136 years, any reasonable TWAP horizon (minutes to hours, occasionally days) is way under that ceiling.
The only failure mode is if you try to compute a TWAP across more than 136 years of elapsed time. That is not a realistic concern.
Why Solidity 0.8 doesn't break this
Solidity 0.8 introduced default revert-on-overflow for arithmetic operations. If you write a - b and b > a for unsigned types, the operation reverts.
So shouldn't uint32(60) - uint32(4294967200) revert under Solidity 0.8?
It would, if the code did exactly that. Uniswap V2 was originally written for Solidity 0.5, where unsigned subtraction was naturally modular. When the code is ported to 0.8, fork authors have to wrap the subtraction in unchecked { ... } to preserve the wraparound behavior.
In the original Uniswap V2 source, the relevant line is in _update():
uint32 blockTimestamp = uint32(block.timestamp % 2**32);
uint32 timeElapsed = blockTimestamp - blockTimestampLast;
Source: UniswapV2Pair.sol:75-76
For Solidity 0.8 forks, the second line must be wrapped in unchecked:
uint32 blockTimestamp = uint32(block.timestamp % 2**32);
uint32 timeElapsed;
unchecked {
timeElapsed = blockTimestamp - blockTimestampLast;
}
Without the unchecked, your TWAP oracle reverts the first time anyone tries to update it after the wrap. The unchecked block is what preserves the elegant wrap-handling that uint32 enables.
This connects to a broader pattern: in Uniswap V2's _update(), two pieces of math need exactly opposite handling. The reserve writes must revert on overflow (which is the default in 0.8). The price accumulator and the timestamp arithmetic must allow overflow (which requires unchecked). We have a separate article on this: Same Function, Opposite Arithmetic: Uniswap V2's _update().
What the code actually looks like
Here is the relevant section of _update() from the original Uniswap V2:
function _update(
uint balance0,
uint balance1,
uint112 _reserve0,
uint112 _reserve1
) private {
require(
balance0 <= uint112(-1) && balance1 <= uint112(-1),
'UniswapV2: OVERFLOW'
);
uint32 blockTimestamp = uint32(block.timestamp % 2**32);
uint32 timeElapsed = blockTimestamp - blockTimestampLast;
if (timeElapsed > 0 && _reserve0 != 0 && _reserve1 != 0) {
// accumulate price oracle
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 (canonical source has the function signature on one line)
Two important things in this function. First, block.timestamp is truncated to uint32 explicitly via % 2**32. This is the wrap mechanic. Second, timeElapsed is computed by subtracting blockTimestampLast from blockTimestamp. Both are uint32. The subtraction is modular. The result is the elapsed time, correct across wraps.
Notice also: blockTimestampLast is what gets stored in the packed slot. It is the last truncated timestamp, not the original block.timestamp. So the value in the slot is always between 0 and 2^32 - 1, regardless of the actual wall-clock time.
What "fixing" the timestamp to uint64 would actually do
Suppose a fork author looks at this and decides "we should be safe past 2106, let's use uint64". The change might look like:
uint112 reserve0;
uint112 reserve1;
uint64 blockTimestampLast; // changed from uint32
Three things break.
1. Storage packing fails. 112 + 112 + 64 = 288 bits, which exceeds 256. The compiler pushes blockTimestampLast into slot 1. Every swap that reads the timestamp via _update() now does an extra cold SLOAD. 2100 gas per swap, multiplied by every swap on every pair, is the cost of the "fix".
2. The truncation logic becomes wrong. The line uint32 blockTimestamp = uint32(block.timestamp % 2**32) was correct for a uint32 storage type. With uint64 storage, you'd want uint64(block.timestamp) (no truncation needed for the foreseeable future). If the fork author leaves the original truncation in place, they now have a uint64 storage variable that only ever holds uint32-range values. The 2106 wrap still happens. The "fix" did nothing for correctness, just hurt gas.
3. Subtraction now requires casting. timeElapsed = blockTimestamp - blockTimestampLast was a clean uint32 - uint32 = uint32 operation. With one operand at uint32 and the other at uint64, you now have a type mismatch. The fork author either casts down (losing the upgrade) or casts up (which changes the modular arithmetic in subtle ways).
In every case, the "fix" is a regression. The original was right.
The 2106 question
Is 2106 a real concern? In a strict accounting sense, yes. In a practical sense, no.
By 2106:
- Uniswap V2 will likely have been superseded by V3, V4, V5, V10. We are already on V4 in 2026.
- Pools deployed in 2020 that haven't been touched in 86 years are unlikely to be active.
- Any oracle consumer reading a Uniswap V2 pool's TWAP across a wrap will get correct answers automatically due to the modular subtraction.
The only realistic failure mode is a long-lived smart contract that does math on blockTimestampLast directly without subtracting from a comparable cumulative value. We have not seen such a pattern in any production protocol that integrates with Uniswap V2.
If a future-Uniswap-V2 deployment in 2105 wanted to migrate to a fresh contract before the wrap, they could do so in a single transaction (deploy new pair, migrate liquidity). The wrap would not cause any protocol-stopping failure. It would just be a planned migration moment.
Related questions
What if I want a timestamp that doesn't wrap until much later? Use uint64. You give up the slot packing and pay the gas penalty. Some protocols make this tradeoff because they don't trust their consumers to handle wraps correctly. Uniswap V2 trusts its consumers because the only legitimate use of the timestamp is via subtraction, which works.
Is the truncation at line block.timestamp % 2**32 strictly necessary? Yes. block.timestamp is a uint256 in the EVM. If you cast it to uint32 directly via uint32(block.timestamp), the cast is implementation-defined. Truncating explicitly via % 2**32 makes the wrap deterministic and explicit.
Does Uniswap V3 have the same wrap behavior? No. V3 uses a different timestamping approach because its tick-based architecture doesn't need the same TWAP-via-cumulatives pattern. V3 has its own oracle with its own timestamp handling.
What about other forks of Uniswap V2 (SushiSwap, PancakeSwap, etc.)? They inherit the uint32 timestamp by default. As far as we know, none of the major forks have widened the type. The packing pattern is universal across the V2 family.
Where to see it in the source
The relevant lines are in UniswapV2Pair.sol::_update():
uint32 blockTimestamp = uint32(block.timestamp % 2**32);
uint32 timeElapsed = blockTimestamp - blockTimestampLast;
Source: UniswapV2Pair.sol:75-76
If you find yourself reading a fork where the timestamp has been widened to uint64 or uint96, write a finding. It is not a security issue per se, but it is a gas regression that the fork's users are paying for invisibly.
When you implement this in Zealynx Academy's reconstruction module, the test suite includes a gas-cost assertion that compares your swap() cost to the canonical Uniswap V2 implementation. Widening the timestamp type fails the assertion by exactly the cost of one extra cold SLOAD.
Tagged