Section 6 of 16

Build
+15 Lynx

Pair: Removing Liquidity (burn)

What You Are Building

The burn() function is the reverse of mint(). A liquidity provider sends their LP tokens back to the pair contract, then calls burn(). The function calculates how much of each token the LP tokens represent, destroys the LP tokens, and transfers the proportional share of both tokens to the recipient.

If mint() is how value enters the pool, burn() is how it leaves. The two functions share the same interaction pattern and the same accounting guardrails, but burn() has its own design subtleties worth understanding in detail.

LP Tokens Sent to the Pair: Why Not Pass an Amount?

Just like mint(), the LP tokens are transferred to the pair contract before burn() is called. The function reads balanceOf[address(this)] to determine how many LP tokens it received. This is the amount being redeemed.

The obvious alternative design is to pass the LP token amount as a function parameter and have burn() call transferFrom to pull tokens from the caller. Uniswap V2 deliberately avoids this. Three reasons.

First, consistency. Every core pair function (mint, burn, swap) follows the same model: tokens arrive first, then the function reads balances to figure out what happened. This makes the pair contract stateless with respect to callers. It does not need to know who sent what. It only knows the difference between current balances and stored reserves.

Second, no approval needed. The pair contract never calls transferFrom on the LP token (which is itself). Users approve the Router, not the pair. This reduces the pair's attack surface.

Third, this pattern enables flash burns. A smart contract could receive tokens from the pair and then send LP tokens back, all in one transaction, without prior approval. The pair does not care about the order of external calls. It just checks balances at the end. This composability is a feature, not an accident.

Proportional Withdrawal: Why Actual Balances, Not Reserves

The withdrawal formula is:

amount0 = liquidity * balance0 / totalSupply
amount1 = liquidity * balance1 / totalSupply

Here, liquidity is the LP token balance the pair received, and balance0 / balance1 are the actual token balances from IERC20(tokenX).balanceOf(address(this)), not the stored reserve0 / reserve1.

Why does this distinction matter? Because actual balances can be higher than reserves. This happens in two cases. First, someone sends tokens directly to the pair without calling any function. These are "donations" that sit in the contract until the next mint, burn, or swap absorbs them. Second, fee-on-transfer or rebasing tokens might change the contract's balance outside of any pair function call.

By using actual balances in the withdrawal calculation, these extra tokens are distributed proportionally to all LPs at the time of withdrawal. If burn() used stored reserves instead, those extra tokens would be invisible and stuck in the contract forever (or until someone called sync()).

This is also why the require(amount0 > 0 && amount1 > 0) check exists. If the LP tokens being burned represent such a tiny fraction of the pool that integer division rounds the withdrawal to zero for either token, the transaction reverts. Without this check, an attacker could burn dust amounts of LP tokens to extract rounding-error value from the pool over many transactions.

The _mintFee() Call: Ordering Matters

Like mint(), the burn() function calls _mintFee() before its main calculation. The purpose is the same: if protocol fees are enabled, _mintFee() mints LP tokens to the fee recipient based on the growth of sqrt(k) since the last collection.

The ordering constraint is critical. _mintFee() may increase totalSupply. If you read totalSupply before calling _mintFee(), the denominator in the withdrawal formula is too small, and the withdrawing LP gets slightly more than their fair share. That excess comes directly from the protocol fee recipient's expected share.

The correct sequence: call _mintFee() first, then read totalSupply. The returned feeOn boolean is stored for the kLast update at the end of the function.

Operation Order: Why Burn Before Transfer

The function burns LP tokens before transferring the underlying tokens to the recipient. This follows the Checks, Effects, Interactions (CEI) pattern for the LP token accounting: the state change (burning) happens before the external calls (transferring tokens out).

However, there is a subtlety. The reserve update via _update() happens after the token transfers. This means the reserves are temporarily stale between the transfers and the _update() call. In a naive design, this would be a reentrancy risk. An attacker could reenter during the transfer and exploit the stale reserves.

Uniswap V2 prevents this with the lock modifier. The entire burn() function is wrapped in a reentrancy guard, so no reentry is possible during the token transfers. This is why the lock modifier you built in an earlier section is essential. Without it, the post-transfer reserve update would be a serious vulnerability.

Related Helpers: skim() and sync()

Two helper functions on the pair contract are related to the balance vs. reserve distinction:

sync() force-updates the stored reserves to match the actual token balances. This is useful when tokens are sent directly to the pair and you want the reserves to reflect reality without performing a mint, burn, or swap. It also resets the oracle accumulator to the current state.

skim() does the opposite. It transfers any excess tokens (balance minus reserve) to a specified address. This is a safety valve for cases where the actual balance exceeds what uint112 reserves can store, or when someone accidentally sends tokens to the pair.

You do not need to implement these, but understanding them helps explain why the pair tracks both reserves and balances separately.

Your Task

Write the complete burn() function body. Here is the logic flow:

  1. Read the current reserves from getReserves(). Store as _reserve0 and _reserve1.
  2. Read the actual token balances via IERC20(tokenX).balanceOf(address(this)). Store as _token0 and _token1 (or reuse the state variable names).
  3. Read the LP token balance of address(this). This is the liquidity being redeemed.
  4. Call _mintFee(_reserve0, _reserve1). Store the returned boolean as feeOn.
  5. Read totalSupply into a local variable _totalSupply. This MUST happen after step 4.
  6. Calculate amount0 = liquidity * balance0 / _totalSupply and amount1 = liquidity * balance1 / _totalSupply.
  7. Require both amount0 > 0 and amount1 > 0. If either is zero, the burn amount is too small.
  8. Burn the liquidity LP tokens from address(this) using _burn(address(this), liquidity).
  9. Transfer amount0 of token0 and amount1 of token1 to the to address using _safeTransfer.
  10. Read the new token balances after the transfers.
  11. Call _update(balance0, balance1, _reserve0, _reserve1) to sync reserves and update the oracle.
  12. If feeOn is true, set kLast = uint(reserve0) * reserve1.
  13. Emit the Burn event with msg.sender, amount0, amount1, and to.

Your Code

Solution.sol
Solidity
Loading editor...

Requirements

burn reads LP token balance from address(this)
burn reads token balances
burn calls _mintFee before calculation
burn calculates proportional amount0
burn calculates proportional amount1
burn requires amounts > 0
burn calls _burn on address(this)
burn transfers token0 via _safeTransfer
burn transfers token1 via _safeTransfer
burn calls _update after transfers
burn emits Burn event