Found Academy useful? A $5 donation by May 14 helps us ship more, faster. Every donor counts (QF matching).

Donate

Section 3 of 16

Build
+15 Lynx

Pair: State and Initialization

Key takeaway: Uniswap V2's pair contract packs uint112 reserve0, uint112 reserve1, and uint32 blockTimestampLast into a single 256-bit storage slot, saving 2,100 gas on every swap, mint, and burn that reads reserves. The uint112 ceiling fits roughly 5.19e33 tokens, more headroom than any practical token needs, and the uint32 timestamp wraps in January 2106 by design. The TWAP oracle only ever subtracts cumulative timestamps, and modular subtraction handles the wrap correctly without intervention.

What You Are Building

UniswapV2Pair is the core AMM contract. It inherits UniswapV2ERC20 (the LP token you just built) and adds everything that makes the constant product market maker work: reserves, the oracle, minting, burning, and swapping.

In this section you are setting up the foundation. The state variables, the initialization function, a safe ERC20 transfer helper, the reentrancy guard, and the reserve getter. Nothing here moves tokens or enforces invariants yet. That comes in the next sections. But every function in the pair depends on what you write here. Get this wrong and every subsequent section breaks.

State Variables and Storage Packing

The EVM reads and writes storage in 256 bit (32 byte) slots. Each SLOAD costs 2100 gas on a cold read. Uniswap V2 stores the two reserves and a timestamp in a single slot:

uint112 private reserve0;
uint112 private reserve1;
uint32  private blockTimestampLast;

112 + 112 + 32 = 256 bits. One slot. This is the most consequential gas optimization in the entire protocol, and here is why.

Every single swap reads both reserves. Every mint reads them. Every burn reads them. The _update function writes them. If reserve0 and reserve1 were uint256, they would live in two separate storage slots. Reading both would cost two SLOADs (4200 gas) instead of one (2100 gas). That 2100 gas difference hits every interaction. Over millions of swaps, this saves an enormous amount of gas across the ecosystem.

Why uint112 specifically? The max value of a uint112 is approximately 5.19 * 10^33. For a token with 18 decimals, that represents about 5.19 * 10^15 whole tokens. To put that in perspective, the total supply of most tokens is nowhere near this ceiling. ETH's total supply is roughly 120 million (1.2 * 10^8). Even the most inflated meme tokens rarely exceed 10^15 in raw supply. The uint112 ceiling is generous enough for any practical token while leaving exactly 32 bits for the timestamp.

Why uint32 for blockTimestampLast? A uint32 timestamp overflows at 2^32 seconds, which is January 19, 2106. Uniswap V2 was deployed in 2020. That gives roughly 86 years of headroom, which is sufficient for a protocol that will almost certainly be superseded before then. The contract truncates block.timestamp to uint32 via modular arithmetic (block.timestamp % 2^32), which means the value wraps gracefully rather than reverting. This truncation is intentional and safe because the TWAP oracle only ever computes differences between timestamps, and modular subtraction produces correct results even across a wrap boundary.

The initialize() Function

Pairs are deployed by the Factory using CREATE2. The Factory calls initialize(token0, token1) exactly once, immediately after deployment. This sets which two tokens the pair trades.

The natural question: why not pass the token addresses as constructor arguments? The answer is CREATE2 address determinism.

The CREATE2 opcode computes a contract's address as keccak256(0xff, factory, salt, init_code_hash). The init code is the contract's creation bytecode, which includes constructor arguments appended at the end. If token addresses were constructor arguments, every pair would have different init code and therefore a different init code hash. That would mean the Factory (and the Library, and the Router, and every external integration) could not compute a pair's address without knowing the specific init code hash for that particular token combination.

By making the constructor take no arguments, all pairs share identical creation bytecode. The init code hash becomes a universal constant. The Factory uses keccak256(abi.encodePacked(token0, token1)) as the salt, and anyone can compute any pair's address from just the factory address, the two token addresses, and one fixed hash. The Library's pairFor function relies on exactly this property.

The initialize function must be restricted to the factory address. The constructor stores msg.sender as the factory, and initialize checks require(msg.sender == factory). There is no other access control. Since the Factory calls initialize in the same transaction as deployment, there is no window for frontrunning.

The _safeTransfer() Helper

If you call IERC20(token).transfer(to, amount) with Solidity's high level interface, the compiler expects the call to return a bool. Most ERC20 tokens do return true on success. But some do not. The most notorious example is USDT (Tether), whose transfer function returns nothing at all. If you use the standard interface against USDT, Solidity will try to decode the empty return data as a bool, fail, and revert. Your contract would be unable to transfer USDT.

Uniswap V2 solves this with a low level call pattern:

(bool success, bytes memory data) = token.call(
    abi.encodeWithSelector(IERC20.transfer.selector, to, value)
);
require(success && (data.length == 0 || abi.decode(data, (bool))));

This works in three stages. First, abi.encodeWithSelector constructs the calldata manually: the 4 byte function selector for transfer(address,uint256) followed by the ABI encoded arguments. Second, .call() executes the low level call and returns a success boolean plus the raw return data. Third, the require statement checks two things: the call did not revert (success == true), and the return data is either empty (for tokens like USDT that return nothing) or decodes to true (for compliant tokens).

This pattern rejects three failure modes: the call reverts (success is false), the call returns false (non reverting failure signal from the token), or the call returns malformed data that does not decode to a bool. It accepts two success modes: returns true (standard) or returns nothing (USDT style).

Without this pattern, Uniswap V2 would be incompatible with a significant portion of tokens in circulation. The same pattern appears in OpenZeppelin's SafeERC20 library, which was partially inspired by this exact use case.

The lock Modifier

Reentrancy is one of the most dangerous vulnerability classes in smart contracts. The classic attack: contract A calls contract B, contract B calls back into contract A before A has finished executing. If A's state is inconsistent at that point (tokens sent but balances not updated), the reentrant call can exploit the intermediate state.

In Uniswap V2, the swap function sends tokens to an arbitrary address, which could be a malicious contract. That contract's receive() or fallback() function could call back into swap(), mint(), or burn() before the first swap finishes updating reserves. The reentrancy guard prevents this.

The implementation uses a uint256 variable called unlocked, initialized to 1:

uint private unlocked = 1;
modifier lock() {
    require(unlocked == 1, 'UniswapV2: LOCKED');
    unlocked = 0;
    _;
    unlocked = 1;
}

When a locked function is entered, unlocked is set to 0. The function body executes. Then unlocked is restored to 1. If any reentrant call tries to enter a locked function during execution, it sees unlocked == 0 and reverts.

Two design details matter here. First, unlocked is a uint256, not a bool. This is a gas optimization. The EVM operates on 256 bit words natively. When you use a bool, the compiler must mask the value to a single bit on every read, which costs extra gas. Using a uint256 and checking against 1 avoids this overhead. Second, the values are 1 and 0, not 0 and 1. Setting a storage slot from 0 to a nonzero value costs 20,000 gas (SSTORE to a fresh slot). Setting it from nonzero to nonzero costs only 5,000 gas. By initializing unlocked to 1 and toggling between 1 and 0, the first call does not pay the 20,000 gas penalty. The variable is always nonzero after construction, so every subsequent write is the cheaper nonzero to nonzero case.

The lock modifier is applied to mint(), burn(), and swap(). These are the three functions that transfer tokens and could be exploited via reentrancy. View functions and internal helpers do not need the guard.

Your Task

The starter code gives you the full contract declaration with all state variables, events, and constants already defined. You need to implement four things:

  1. initialize(): Set token0 and token1. Only callable by factory.
  2. getReserves(): Return reserve0, reserve1, and blockTimestampLast from storage.
  3. _safeTransfer(): Execute a low level ERC20 transfer that handles non compliant tokens.
  4. lock modifier: The reentrancy guard using the unlocked state variable.

Your Code

Solution.sol
Solidity
Loading editor...

Requirements

Write your implementation, then click Run Tests. Tests execute on the server.