Section 2 of 16

Build
+15 Lynx

The LP Token (UniswapV2ERC20)

What You Are Building

Every Uniswap V2 pair is also an ERC-20 token. When you add liquidity, the pair contract mints LP tokens to you. When you remove liquidity, it burns them. These LP tokens represent your share of the pool's reserves.

UniswapV2ERC20 is the base contract that UniswapV2Pair inherits from. It implements the full ERC-20 standard (transfer, approve, transferFrom) plus one critical addition: permit(), which implements EIP-2612 gasless approvals. This is the first contract you build because everything else depends on it.

Why This Contract Matters

Without LP tokens, there is no way to track who owns what share of a liquidity pool. When Alice adds 10 ETH and 20,000 USDC, and Bob adds 5 ETH and 10,000 USDC, the pool holds 15 ETH and 30,000 USDC total. LP tokens track that Alice owns 2/3 of the pool and Bob owns 1/3.

The LP token is not a separate contract. It is the Pair contract itself, which inherits from UniswapV2ERC20. This is an important design choice. When the Pair contract calls _mint(to, liquidity), it mints LP tokens to the liquidity provider directly. When it calls _burn(from, liquidity), it burns them. No external token contract is involved. One contract does everything.

Function by Function

_mint(address to, uint256 value)

This is an internal function. Only the inheriting Pair contract can call it, and it does so inside its own mint() function when liquidity is added.

The implementation is simple: increase totalSupply by value, increase balanceOf[to] by value, emit a Transfer event from address(0) to to. The zero address as sender is the ERC-20 convention for newly created tokens.

In Solidity 0.8.x, the += operator has built-in overflow checking. If totalSupply + value would exceed type(uint256).max, the transaction reverts automatically. In the original Uniswap V2 (Solidity 0.5.16), this was handled by SafeMath. We do not need SafeMath.

_burn(address from, uint256 value)

The reverse of _mint. Decrease balanceOf[from] by value, decrease totalSupply by value, emit Transfer from from to address(0).

The order matters subtly: decrease the balance first, then totalSupply. If from does not have enough tokens, the subtraction reverts (Solidity 0.8.x underflow check). This is the authorization model for burning. Only the Pair contract calls _burn, and it only burns tokens that have been explicitly transferred to the Pair's own address first.

_approve(address owner, address spender, uint256 value)

A private helper used by both approve() and permit(). Sets allowance[owner][spender] = value and emits Approval. Nothing more.

_transfer(address from, address to, uint256 value)

Decreases balanceOf[from] by value, increases balanceOf[to] by value, emits Transfer. If from does not have enough tokens, the subtraction reverts.

approve(address spender, uint256 value) → bool

Calls _approve(msg.sender, spender, value) and returns true. This is the standard ERC-20 approval.

transfer(address to, uint256 value) → bool

Calls _transfer(msg.sender, to, value) and returns true. Standard ERC-20 transfer.

transferFrom(address from, address to, uint256 value) → bool

This is where Uniswap adds its own optimization. The standard ERC-20 flow is: read the allowance, decrease it by value, then call _transfer. Uniswap adds one check first:

if (allowance[from][msg.sender] != type(uint256).max) {
    allowance[from][msg.sender] -= value;
}

If the allowance is set to the maximum uint256 value, the contract treats it as "infinite" and does not decrease it. This saves gas for the Router contract, which is typically approved with max allowance and makes many transfers. Without this optimization, every swap through the Router would spend ~5,000 extra gas updating the allowance storage slot.

permit(owner, spender, value, deadline, v, r, s)

This is the most complex function in this contract. It implements EIP-2612, which allows approvals to be set using an off-chain signature instead of an on-chain transaction.

The flow:

  1. Check that deadline >= block.timestamp. Expired signatures must not be accepted.
  2. Build the EIP-712 digest. This is a hash of the structured data:
    • The \x19\x01 prefix (EIP-191)
    • The DOMAIN_SEPARATOR (unique per chain and contract address, computed in the constructor)
    • A hash of the permit data: PERMIT_TYPEHASH, owner, spender, value, nonces[owner], deadline
  3. Increment nonces[owner]. This happens inside the hash computation (nonces[owner]++). It prevents signature replay. Each signature can only be used once.
  4. Call ecrecover(digest, v, r, s) to recover the signer's address from the signature.
  5. Verify the recovered address is not zero (invalid signature) and equals owner. If someone submits a signature that was not signed by the owner, this check fails.
  6. Call _approve(owner, spender, value).

Why does this matter? Without permit, adding liquidity requires two transactions: one to approve the Router, one to add liquidity. With permit, the user signs the approval off-chain (free), and the Router can call permit + addLiquidity in a single transaction. This is used by removeLiquidityWithPermit in the Router.

Your Task

The starter code gives you the contract declaration, state variables, events, and constants. The constructor is implemented (it computes the DOMAIN_SEPARATOR). You need to implement all 8 function bodies.

Start with the simple ones (_mint, _burn, _approve, _transfer), then approve, transfer, transferFrom (with the infinite allowance check), and finally permit (the most complex).

Your Code

Solution.sol
Solidity
Loading editor...

Requirements

_mint increases totalSupply
_mint increases recipient balance
_mint emits Transfer from zero address
_burn decreases balance
_burn decreases totalSupply
_transfer moves balances
_approve sets allowance
transferFrom checks infinite allowance
permit verifies deadline
permit uses ecrecover
permit verifies recovered address
permit increments nonce