Section 13 of 16
Router: Removing Liquidity
What You Are Building
Removing liquidity is the reverse of adding it. The user sends LP tokens back to the pair, the pair burns them, and the underlying tokens are returned proportionally. But the Router does not just forward the call. It adds slippage protection, deadline enforcement, ETH unwrapping, and gasless approval via EIP-2612 permit. Each of these protections exists because without it, users lose funds in predictable ways.
removeLiquidity
This is the base removal function. Every other removal variant either calls this function directly or replicates its logic with modifications. Understanding the exact order of operations is critical.
Step 1: Compute the pair address. Call UniswapV2Library.pairFor(factory, tokenA, tokenB) to deterministically compute the pair's CREATE2 address. No external call needed.
Step 2: Transfer LP tokens to the pair. Call IUniswapV2Pair(pair).transferFrom(msg.sender, pair, liquidity). This moves LP tokens from the user directly to the pair contract itself. The user must have previously approved the Router to spend their LP tokens (or used the permit variant, covered below).
Step 3: Call pair.burn(to). The pair's burn() function reads its own LP token balance (the tokens just transferred in Step 2), calculates the proportional share of each reserve, burns the LP tokens, and sends both underlying tokens to the to address. It returns (amount0, amount1) representing the actual amounts sent.
Step 4: Sort and validate amounts. The pair returns amounts in token0/token1 order (sorted by address). The Router must map these back to tokenA/tokenB order to compare against the user's minimums. If tokenA == token0, then amountA = amount0. Otherwise amountA = amount1. Then it checks require(amountA >= amountAMin) and require(amountB >= amountBMin).
Why the order matters. You must transfer LP tokens to the pair before calling burn(). The pair's burn() function does not take an amount parameter. Instead, it reads balanceOf(address(this)) for its own LP token. Whatever LP tokens are sitting in the pair contract at that moment get burned. This is the same transfer-then-call pattern used in mint(). If you call burn() first, the pair sees zero LP tokens to burn and sends nothing back. If another user manages to transfer LP tokens to the pair between your transfer and your burn call, those tokens get burned too. In practice, this is extremely unlikely within a single transaction, but the pattern makes the pair contract stateless with respect to pending operations. No mapping of "who deposited what" is needed.
What goes wrong without minimum checks. Between the time a user submits a removal transaction and when it gets mined, the pool ratio can shift. A large swap might change the reserves dramatically. Without minimums, the user might burn LP tokens worth 10 ETH + 21,000 USDC but receive 8 ETH + 17,000 USDC because a whale dumped into the pool. The minimums act as slippage protection. If the returned amounts fall below the thresholds, the entire transaction reverts.
removeLiquidityETH
When one of the pool tokens is WETH, the user expects to receive native ETH, not an ERC-20 WETH token. The Router cannot simply call removeLiquidity() with the user as recipient, because the pair would send WETH directly to the user. WETH tokens sitting in a user's wallet are not ETH. They need to be unwrapped.
Why the Router receives tokens first. The function calls removeLiquidity() with address(this) (the Router itself) as the to address. The pair sends both tokens to the Router. Then the Router forwards them separately.
Step 1: Remove liquidity to Router. Call removeLiquidity(token, WETH, liquidity, amountTokenMin, amountETHMin, address(this), deadline). The Router now holds both the ERC-20 token and WETH.
Step 2: Transfer the ERC-20 token. Call TransferHelper.safeTransfer(token, to, amountToken) to send the non-WETH token directly to the user.
Step 3: Unwrap WETH. Call IWETH(WETH).withdraw(amountETH). The WETH contract burns the Router's WETH and sends native ETH back to the Router. This is why the Router has a receive() function. It must accept incoming ETH from the WETH contract.
Step 4: Send ETH to user. Call TransferHelper.safeTransferETH(to, amountETH). The user receives native ETH.
Why not send WETH directly to the user and let them unwrap? User experience. Most users do not know what WETH is. They hold ETH, they want ETH back. If the Router sent WETH, users would need a separate unwrap transaction. The Router handles this automatically, at the cost of slightly more gas.
removeLiquidityWithPermit
Without permit, removing liquidity is a two-transaction process. First, the user calls approve(router, amount) on the LP token contract. Then, the user calls removeLiquidity(). Two transactions means two gas payments, two confirmations, and a worse user experience.
EIP-2612 permit eliminates the first transaction. The user signs an off-chain message (free, no gas) that authorizes the Router to spend a specific amount of LP tokens. The Router submits this signature on-chain as part of the removal call.
Function flow. The function takes all the normal removal parameters plus the permit parameters: approveMax, deadline (for the permit, separate from the Router deadline), v, r, s.
The approveMax flag. If approveMax is true, the function calls pair.permit(msg.sender, address(this), type(uint256).max, deadline, v, r, s). This sets an unlimited approval. Future removals will not need another permit signature because the approval never depletes. If approveMax is false, the permit approves exactly the liquidity amount being removed. After this removal, the approval drops to zero and the next removal needs a fresh permit.
After the permit. The function simply calls removeLiquidity() with all the same parameters. The approval is now set, so transferFrom inside removeLiquidity succeeds.
Why this matters in practice. This is the function that makes EIP-2612 useful. The permit standard defines the mechanism (signature-based approvals), but without a function like this that integrates the permit call with the actual operation, users still need to submit permits as separate transactions. By bundling them, the Router delivers the full promise of gasless approvals: one signature, one transaction.
Edge case: permit replay. The permit includes a nonce that increments with each use, preventing replay. If a user signs two permits with the same nonce, only the first one submitted on-chain succeeds. The second reverts.
removeLiquidityETHWithPermit
This function combines both extensions: permit-based approval and ETH unwrapping. The logic is straightforward once you understand the two components.
Step 1: Execute permit. Same logic as removeLiquidityWithPermit. Check approveMax, call pair.permit() with either type(uint256).max or the exact liquidity amount.
Step 2: Remove liquidity with ETH unwrapping. Call removeLiquidityETH() instead of removeLiquidity(). The Router receives both tokens, forwards the ERC-20, unwraps WETH, and sends ETH.
The result: a user can go from holding LP tokens to holding native ETH plus the other token in a single transaction, without any prior approval call.
Your Task
Implement all four removal functions. The starter code gives you the function signatures and the infrastructure from the previous section (TransferHelper, IWETH interface, ensure modifier). You write the function bodies.