Section 12 of 16
Router: Adding Liquidity
What You Are Building
The Router's liquidity functions are the safe entry point for adding tokens to a Uniswap V2 pair. Users never interact with the pair contract directly. They call addLiquidity() or addLiquidityETH() on the Router, which handles optimal amount calculation, pair creation, token transfers, and LP minting. Getting these functions right means understanding why each step exists, what breaks without it, and how the Router protects users from common pitfalls.
The ensure(deadline) Modifier
Every external Router function carries the ensure(deadline) modifier. It checks one thing: require(block.timestamp <= deadline). If the current block timestamp exceeds the deadline the user provided, the transaction reverts.
Why this matters: Ethereum transactions can sit in the mempool for minutes or hours, especially during congestion or if the gas price is set too low. In that time, the pool ratio can shift dramatically. A user who submitted a transaction expecting a 1:2000 ETH/USDC ratio might have it execute at 1:1800 after a market crash. The deadline gives users a hard cutoff. If the transaction does not get mined in time, it fails cleanly instead of executing at a stale price. Without this modifier, every liquidity operation would be vulnerable to delayed execution attacks.
The _addLiquidity Internal Function
Before any tokens move, the Router must determine the exact amounts to deposit. This is the job of _addLiquidity(), an internal function that never touches tokens directly. It only computes and validates.
Pair creation. The function starts by checking if the pair exists. It calls factory.getPair(tokenA, tokenB) and if the result is the zero address, it calls factory.createPair(tokenA, tokenB). For a brand new pair with zero reserves, the desired amounts are used exactly as provided. There is no existing ratio to honor, so the first depositor sets the initial price.
The optimal amount calculation. When the pair already has reserves, the function must figure out the right ratio. Suppose a pool has 100 ETH and 210,000 USDC (ratio 1:2100). A user wants to add 10 ETH and 20,000 USDC. The function calls UniswapV2Library.quote(amountADesired, reserveA, reserveB) to compute the optimal amount of tokenB given the desired amount of tokenA. In this example, 10 ETH at a 1:2100 ratio requires 21,000 USDC. But the user only offered 20,000 USDC, so amountBOptimal (21,000) exceeds amountBDesired (20,000). This direction fails.
Trying the other direction. The function then flips: compute the optimal amount of tokenA given amountBDesired. With 20,000 USDC at a 1:2100 ratio, the optimal ETH is approximately 9.52 ETH. The function checks that 9.52 >= amountAMin. If it passes, the final amounts are (9.52 ETH, 20,000 USDC). The user deposits less ETH than desired but stays within the acceptable range. The key insight: the function always tries to use as much of one token as possible while capping the other, then checks minimums. It never forces a bad ratio.
What goes wrong without this function. If you skip the optimal calculation and just deposit both desired amounts at an incorrect ratio, the extra tokens would sit in the pair contract unaccounted for. They would be claimable by the next user who calls skim() or sync(), meaning the depositor simply loses them. The _addLiquidity function exists to prevent this loss entirely.
Edge case: amountMin too tight. If both directions fail the minimum checks, the function reverts with UniswapV2Router: INSUFFICIENT_A_AMOUNT or INSUFFICIENT_B_AMOUNT. This happens when the user sets minimums that are incompatible with the current pool ratio. It is a feature, not a bug. The user chose those bounds to protect against slippage, and the contract respects them.
addLiquidity
The external addLiquidity() function ties the pieces together in four steps.
Step 1: Compute amounts. Call _addLiquidity() with the desired amounts, minimums, and token addresses. This returns the actual amounts to deposit.
Step 2: Compute pair address. Call UniswapV2Library.pairFor(factory, tokenA, tokenB). This uses CREATE2 to deterministically compute the pair address without an external call. The pair might have just been created inside _addLiquidity(), but the address is predictable regardless.
Step 3: Transfer tokens. Call TransferHelper.safeTransferFrom(tokenA, msg.sender, pair, amountA) and the same for tokenB. This moves tokens from the user directly to the pair contract. The Router never holds the tokens. This is important: the pair's mint() function measures how many tokens it received by comparing its current balance to its stored reserves. The tokens must arrive at the pair before mint() is called.
Step 4: Mint LP tokens. Call IUniswapV2Pair(pair).mint(to), which mints LP tokens proportional to the deposited amounts and sends them to the to address.
Why TransferHelper.safeTransferFrom instead of raw transfer. The ERC-20 standard says transfer() and transferFrom() should return a boolean. Many tokens follow this. Some do not. USDT on mainnet, one of the most traded tokens in existence, does not return a boolean from its transfer functions. If you call IERC20(usdt).transferFrom(...) in Solidity, the compiler generates code that expects a return value. When USDT returns nothing, the call reverts. TransferHelper.safeTransferFrom uses a low-level call() and only checks the return data if it exists. If the call succeeds with no return data, it passes. If it returns true, it passes. If it returns false or reverts, it fails. This single function makes the Router compatible with virtually every ERC-20 token on mainnet, including non-compliant ones.
addLiquidityETH
Users do not hold WETH. They hold ETH. The addLiquidityETH() function bridges this gap.
Step 1: Compute optimal amounts. Call _addLiquidity() with WETH as one of the tokens. The function works identically because WETH is a standard ERC-20 and the pair treats it the same as any other token.
Step 2: Transfer the ERC-20 token. Move the non-ETH token from the user to the pair using safeTransferFrom.
Step 3: Wrap ETH. Call IWETH(WETH).deposit{value: amountETH}() to convert exactly the optimal amount of ETH into WETH. This sends ETH to the WETH contract, which mints WETH 1:1.
Step 4: Transfer WETH to pair. Call IWETH(WETH).transfer(pair, amountETH) to move the freshly minted WETH to the pair.
Step 5: Mint LP tokens. Same as before.
Step 6: Refund excess ETH. The user sent msg.value ETH with the transaction. The optimal amount might be less. If the user sent 10 ETH but only 9.52 was needed, the Router sends back 0.48 ETH via TransferHelper.safeTransferETH(msg.sender, msg.value - amountETH). Without this refund, the excess ETH would be trapped in the Router contract forever, effectively burned. This is why the Router has a receive() function. It must be able to accept ETH from the WETH contract during withdrawal, and it must be able to send ETH back to users.
Edge case: msg.value exactly right. If msg.value equals the optimal ETH amount, the refund is zero and the transfer is skipped. No gas wasted.
Your Task
The starter code gives you the contract shell with immutables (factory, WETH), the ensure modifier, all TransferHelper functions, the IWETH interface, and the receive() function. You need to implement _addLiquidity(), addLiquidity(), and addLiquidityETH().