Section 6 of 16
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:
- Read the current reserves from
getReserves(). Store as_reserve0and_reserve1. - Read the actual token balances via
IERC20(tokenX).balanceOf(address(this)). Store as_token0and_token1(or reuse the state variable names). - Read the LP token balance of
address(this). This is theliquiditybeing redeemed. - Call
_mintFee(_reserve0, _reserve1). Store the returned boolean asfeeOn. - Read
totalSupplyinto a local variable_totalSupply. This MUST happen after step 4. - Calculate
amount0 = liquidity * balance0 / _totalSupplyandamount1 = liquidity * balance1 / _totalSupply. - Require both
amount0 > 0andamount1 > 0. If either is zero, the burn amount is too small. - Burn the
liquidityLP tokens fromaddress(this)using_burn(address(this), liquidity). - Transfer
amount0of token0 andamount1of token1 to thetoaddress using_safeTransfer. - Read the new token balances after the transfers.
- Call
_update(balance0, balance1, _reserve0, _reserve1)to sync reserves and update the oracle. - If
feeOnis true, setkLast = uint(reserve0) * reserve1. - Emit the
Burnevent withmsg.sender,amount0,amount1, andto.