How Uniswap V2 Saves 2100 Gas Per Swap with Storage Packing
Uniswap V2 packs reserve0, reserve1, and blockTimestampLast into a single 256-bit storage slot. The math, the tradeoffs, and why this is the most consequential gas optimization in DeFi.
TL;DR
- Uniswap V2 stores
reserve0(uint112) +reserve1(uint112) +blockTimestampLast(uint32) in a single 256-bit storage slot. - One slot read instead of two saves 2100 gas per cold SLOAD on every swap, mint, and burn.
- The choice of uint112 is not arbitrary. The max value is approximately 5.19 * 10^33, which fits any practical token supply with room to spare and leaves exactly 32 bits for the timestamp.
- The
getReserves()function returns all three values in a single tuple, so the pattern is invisible to callers but baked into the gas cost of every interaction. - Most forks copy this pattern correctly. The forks that don't, by widening reserves to uint256 for "safety", silently double the gas cost of every swap on their pool. At scale this is one of the most expensive non-bugs in DeFi.
Why this matters
If you have ever wondered why Uniswap V2 looks the way it looks (terse, almost cramped, with packed structs and bit-level type choices), the storage layout in UniswapV2Pair.sol is the answer. It is the single most-impactful gas optimization in the protocol, and it is also the one that fork authors most commonly "improve" by accident.
You can fork Uniswap V2 line by line, change three character ranges (uint112 to uint256), and ship something that looks identical but costs roughly 4200 extra gas per swap. Multiply that across hundreds of swaps per day across every pair on your fork, and you have shipped a multi-million-gas regression that nobody will ever notice unless they read your contract side-by-side with the original.
Understanding why those three types are exactly what they are is one of the better tests of whether you actually understand EVM storage. It is also how you avoid being the person whose fork costs 50% more gas than the original.
EVM storage costs, in three sentences
The EVM stores contract state in 32-byte slots. Reading a cold slot (one not yet read in the current transaction) costs 2100 gas. Reading a warm slot (already read this transaction) costs 100 gas.
Most of what your contract does involves reading and writing those slots. If you can fit two values into one slot, you read both for the price of one.
That is the entire premise of storage packing.
What Uniswap V2 packs
Open UniswapV2Pair.sol and look at the state variables near the top of the contract:
uint112 private reserve0; // uses single storage slot, accessible via getReserves
uint112 private reserve1; // uses single storage slot, accessible via getReserves
uint32 private blockTimestampLast; // uses single storage slot, accessible via getReserves
The comments are not idle. The author is telling you, in three lines, that all three of these variables share a single storage slot.
The math: 112 + 112 + 32 = 256 bits = exactly one slot. The Solidity compiler packs sequentially-declared state variables into the same slot if the total fits in 256 bits. Declared in this order, all three live in slot zero of the contract's storage layout.
When getReserves() runs, it does one SLOAD and decomposes the result into the three return values:
function getReserves() public view returns (
uint112 _reserve0,
uint112 _reserve1,
uint32 _blockTimestampLast
) {
_reserve0 = reserve0;
_reserve1 = reserve1;
_blockTimestampLast = blockTimestampLast;
}
This looks like three separate reads. The compiler turns it into one SLOAD plus bit-shifts. You pay 2100 gas once, not three times.
The gas math, with real numbers
Compare two implementations. The original:
uint112 reserve0;
uint112 reserve1;
uint32 blockTimestampLast;
A "safer" alternative that some forks ship:
uint256 reserve0;
uint256 reserve1;
uint32 blockTimestampLast;
In the first, reserves and timestamp share one slot. In the second, reserve0 lives in slot 0, reserve1 lives in slot 1, and blockTimestampLast lives in slot 2.
A swap function reads both reserves and the timestamp. With the packed layout: 1 SLOAD = 2100 gas. With the unpacked layout: 3 SLOADs on the first read of each (the second is warm at 100 gas, but the first is still 2100 cold). Worst case unpacked: 2100 + 2100 + 2100 = 6300 gas. Best case unpacked, all warm: 100 + 100 + 100 = 300 gas. But the first call always pays the cold cost.
Uniswap V2's swap() reads reserves once and the timestamp also gets touched in _update(). The packed layout costs 2100 gas for that whole bundle. The unpacked layout costs 4200 to 6300 gas depending on access patterns.
Multiply by the number of swaps. At Uniswap V2's peak in 2021, the protocol was processing roughly 100,000 trades per day across all pairs. A 2100 gas savings per swap, at a 50 gwei gas price and ETH at $3000, is about $0.32 per trade. Over a day: $32,000. Over a year: $11.7 million in user gas saved.
That is the dollar value of three character changes on three lines.
Why uint112, not uint128 or uint120
The natural question is why the authors picked 112 bits for the reserves. Why not 128 (the next power-aligned size below 256/2) or 120?
Two constraints. The reserves and the timestamp must add up to exactly 256 bits, because the slot has 256 bits and you don't want to waste any. And the timestamp needs at least 32 bits to be useful for any practical TWAP horizon (32 bits = 136 years from epoch).
Given a 32-bit timestamp, the reserves get 224 bits to share between them. That is 112 bits each.
What does 112 bits give you in token-amount terms? 2^112 ≈ 5.19 * 10^33. For a token with 18 decimals, that is 5.19 * 10^15 whole tokens. Five quadrillion. ETH's circulating supply is about 120 million (1.2 * 10^8). Even the most absurd memecoins rarely have more than 10^15 in raw supply, and even those would fit. Stablecoins like USDC use 6 decimals, which gives them an effectively unlimited ceiling under uint112.
The only tokens that don't fit are deliberate edge cases: hyper-inflated meme tokens with quintillions of supply, or experimental designs with absurdly high decimals. Uniswap V2's reaction to those tokens is to revert. The _update() function explicitly checks:
require(
balance0 <= uint112(-1) && balance1 <= uint112(-1),
'UniswapV2: OVERFLOW'
);
If a token's supply genuinely overflows uint112, the pair refuses to update. This is a defensive choice. The pool stops accepting new state until the situation is resolved. In practice, this has happened only for pathological tokens with broken supply mechanics, and the standard reaction is "don't list that token on a Uniswap V2 fork".
Why uint32 for the timestamp
The timestamp gets the leftover 32 bits. A uint32 holds values up to 2^32 = 4,294,967,296. Interpreted as Unix seconds, that means the timestamp wraps to zero on January 19, 2106.
Uniswap V2 was deployed in 2020. The contract is designed to function correctly until 2106 and to wrap gracefully afterward. The wrap is not a bug, it is an explicit design choice. The TWAP oracle only ever computes differences between cumulative timestamps, and modular subtraction handles wraparound correctly.
We have a separate post on this: Why Uniswap V2's Timestamp Wraps in 2106 (and Why It's a Feature). The short version: by wrapping rather than reverting, the protocol stays usable across the 2106 boundary without needing a contract migration.
How the EVM packs and unpacks
The packing is invisible at the Solidity level but very real at the EVM level.
When you write to reserve0, the compiler generates code that:
- Reads slot 0 (the packed slot).
- Masks out the bottom 112 bits.
- ORs in the new
reserve0value. - Writes the modified slot back.
This is more bytecode than a plain SSTORE would be, but the storage cost is what dominates, and storage costs are paid by slot, not by bit. The bit-fiddling overhead is essentially free.
When you read all three via getReserves(), the compiler generates one SLOAD and three shift-and-mask operations to extract each field. SLOAD is 2100 gas. The shifts and masks together are perhaps 30 gas. The ratio is so skewed in favor of the packed read that any gas cost from the bit math is rounding error.
You can see all of this in the assembly output. If you compile UniswapV2Pair with --asm and look at the deployed runtime code, the getReserves selector dispatches to a tiny block that does one SLOAD and three SHRs. The first SHR shifts down to expose blockTimestampLast, the second shifts to expose reserve1, the third leaves reserve0 in place.
Why this is the trap most forks fall into
The mistake we see most often in fork audits is one of two forms.
Form 1: widening for "safety". The fork author sees uint112 and thinks "what if a token's supply exceeds 5 * 10^33?". They change reserves to uint256. They feel safer. They have just doubled the gas cost of every swap by unpacking the slot.
Form 2: reordering for "readability". The fork author moves the variable declarations around because the code "reads better" with the timestamp first or the reserves grouped differently. The Solidity compiler still tries to pack, but if the new ordering puts a uint32 between two uint112s in a way that creates fragmentation, packing fails and the variables spill into a second slot.
Both mistakes are easy to make and impossible to spot from the surface. The function signatures are identical. The behavior is identical. The tests pass. The only signal is the gas counter in your test runner, and only if you compare it to the canonical Uniswap V2 implementation in the same test scenario.
In a Zealynx Academy reconstruction, the test suite runs a baseline gas-cost assertion on swap() against a known reference value derived from the original Uniswap V2 implementation. If your packed layout is correct, you pass. If you "improved" the types, you fail by a recognizable margin (typically 2100 to 4200 gas per swap).
What about the public reserves?
The reserves are declared private, but getReserves() is public. Why the indirection?
Because the Solidity compiler generates a default getter for each public state variable. If reserve0 were public, the compiler would generate function reserve0() returns (uint112), which would do one SLOAD and one mask-and-shift, costing 2100 gas. Calling all three getters from off-chain or from another contract would cost three separate cold SLOADs (the second and third would technically be warm if same transaction, but you pay the cold cost on the first).
By making the reserves private and exposing getReserves() as a single function returning all three, Uniswap V2 forces every consumer (the Router, the Library, external integrators) to pay for one SLOAD instead of three. This is another small efficiency win on top of the slot packing, and it is a good idiom to copy whenever you have a packed slot.
Related questions
Why doesn't Uniswap V3 use the same packing? V3 uses concentrated liquidity, which has a fundamentally different storage layout. Reserves are not the central state variable. V3 packs differently (slot0 holds the current sqrtPriceX96, the current tick, and a few flags), but the principle is the same.
Can I pack three uint80s and a uint16 instead? Yes, you can pack any combination that fits in 256 bits. Uniswap V2's specific choice is driven by the requirements of TWAP oracle correctness and token-supply ceiling. If you have different requirements, different packings can make sense.
What about Solidity's automatic packing for inheritance? State variables from parent contracts are laid out before child contract variables. UniswapV2Pair inherits from UniswapV2ERC20, which has its own state. The compiler's automatic packing rules account for this: the parent's storage layout is laid out first, and child variables follow. As long as you understand the ordering, packing across inheritance works the same way.
Does this matter on L2s where gas is cheap? Less, but still meaningfully. A 2100 gas saving on Optimism or Arbitrum at typical L2 gas prices is fractions of a cent per swap. At L2-scale volume (millions of swaps per day across all DEXes), the aggregate savings is still material. The packing pattern survived the L2 transition because it's still net positive even when each individual gas unit is cheaper.
Where to see it in the source
Three lines, near the top of UniswapV2Pair.sol:
uint112 private reserve0;
uint112 private reserve1;
uint32 private blockTimestampLast;
If you find yourself reading a fork where any of these have been widened to uint256 (or where the order has been changed in a way that breaks packing), write the finding. The fix is to restore the original layout. You will save your users meaningful gas across the lifetime of the protocol.
When you implement these in Zealynx Academy's reconstruction module, the test suite asserts both functional correctness and packed gas cost. You cannot pass with a layout that costs more than the canonical implementation by more than a small tolerance.
Tagged