Section 5 of 16
Pair: Adding Liquidity (mint)
What You Are Building
The mint() function is how liquidity enters the pool. A user sends both tokens to the pair contract, then calls mint(). The function calculates how many LP tokens to issue based on the amounts deposited relative to the existing reserves. These LP tokens represent the user's share of the pool.
This is the first function that actually moves value. Everything you built in the previous sections (reserves, the lock modifier, safe transfers, the oracle) comes together here. Every design choice in mint() exists because of a specific attack vector or accounting requirement. Understanding the why behind each line is what separates someone who can fork Uniswap from someone who can build a DEX from scratch.
The Transfer First Pattern
Uniswap V2 uses an unusual interaction model. Tokens are transferred to the pair contract before mint() is called. The function then reads the contract's actual token balances and compares them to the stored reserves to determine what was deposited:
balance0 = IERC20(token0).balanceOf(address(this));
amount0 = balance0 - _reserve0;
Why not pass amounts as parameters and use transferFrom inside mint()? Two reasons. First, the pair contract never needs to hold approvals. Users approve the Router, which handles transfers and then calls the pair. This separation means the pair contract has a smaller attack surface. Second, this pattern unifies the accounting model across mint(), burn(), and swap(). Every function simply asks: "what changed since the last time reserves were updated?" This makes flash operations possible and keeps the pair logic stateless with respect to callers.
The tradeoff: if someone transfers tokens to the pair without calling mint(), those tokens sit in the contract and get absorbed by the next caller. They become a donation. The Router protects normal users from this by bundling transfers and calls into a single transaction.
First Deposit: The Geometric Mean
When totalSupply == 0, there are no existing LP tokens and no reserves to compare against. The first depositor sets the initial price by choosing how many of each token to provide. The formula for initial liquidity is:
liquidity = sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY
The geometric mean sqrt(amount0 * amount1) is chosen specifically because it makes LP token value independent of the price ratio between the two tokens. Consider the alternative: if you used amount0 + amount1, a depositor could game the system by depositing 1 wei of token0 and 1 billion of token1, inflating the LP supply artificially. With the geometric mean, the LP tokens scale with the product of the amounts. Doubling one token and halving the other gives the same result. This means the LP token supply is denominated in units that reflect the geometric average of both token quantities, which maps cleanly to the constant product invariant k = x * y.
The Uniswap V2 whitepaper proves that this choice ensures the value of a liquidity pool share can never decrease (in the absence of impermanent loss), regardless of what ratio the first depositor chose. Any other formula would allow manipulation of LP token value by choosing a skewed initial deposit.
MINIMUM_LIQUIDITY: Preventing the Share Inflation Attack
On the first deposit, 1000 wei of LP tokens are minted to address(0), permanently locking them. This looks wasteful, but it prevents a critical attack.
The attack works like this. An attacker creates a pool by depositing 1 wei of each token. They receive sqrt(1) = 1 LP token, and they are the sole holder. Now totalSupply = 1. They then donate a massive amount of tokens directly to the pair (without calling mint()), say 1e18 of each token. Now the pair holds 1e18 tokens, but totalSupply is still 1. When the next user tries to deposit, say, 2e18 of each token, their LP calculation is min(2e18 * 1 / 1e18, 2e18 * 1 / 1e18) = 2. But the integer division 2e18 * 1 / 1e18 could round to 1 or even 0 depending on exact amounts. The attacker now holds 1 out of 2 or 3 total LP tokens, but the pool contains the attacker's donated tokens plus the victim's deposit. The victim's share is diluted.
By burning 1000 LP tokens to address(0), the minimum totalSupply is always at least 1000. To execute the share inflation attack profitably, the attacker would need to donate millions of dollars worth of tokens to make rounding errors meaningful against a totalSupply of 1000. This makes the attack economically impractical.
Subsequent Deposits: The min() Rule
When totalSupply > 0, the pool already has a price ratio established by the reserves. The formula is:
liquidity = min(amount0 * totalSupply / _reserve0, amount1 * totalSupply / _reserve1)
Each ratio amountX * totalSupply / reserveX computes what share of the pool the deposit represents for that token. Taking the min() means the depositor gets LP tokens based on whichever token they provided less of relative to the current ratio.
Why min() instead of max() or the average? Because it enforces that depositors match the current ratio or lose value. If you deposit 100 USDC and 200 ETH into a 1:1 pool, you get LP tokens proportional to the 100 USDC side. The extra 100 ETH stays in the pool and benefits existing LPs. This is not a flaw. It is a mechanism that incentivizes depositors to always deposit at the correct ratio, which is exactly what the Router's addLiquidity function calculates for them.
If max() were used instead, depositors could inflate their LP share by dumping extra tokens on one side. That would dilute existing LPs unfairly.
The _mintFee() Call and Its Ordering
Before calculating liquidity, mint() calls _mintFee(). This function checks whether protocol fees are enabled (by reading feeTo from the factory) and, if so, mints LP tokens to the fee recipient based on the growth of sqrt(k) since the last fee collection.
The ordering here is critical. _mintFee() may increase totalSupply by minting protocol fee LP tokens. If you read totalSupply before calling _mintFee(), your liquidity calculation uses a stale denominator, and the new depositor gets slightly more LP tokens than they should, effectively stealing value from the protocol fee recipient.
The correct order: call _mintFee() first, then read totalSupply. The feeOn boolean returned by _mintFee() is stored so that at the end of the function, you know whether to update kLast.
The Math Library
The starter code includes a Math library with min() and sqrt(). The sqrt() implementation is the Babylonian method (Newton's method for square roots), which is the same algorithm Uniswap V2 uses in production. You do not need to write these. They are provided.
Your Task
Write the complete mint() function body. Here is the logic flow, line by line:
- Read the current reserves from
getReserves(). Store them as local variables_reserve0and_reserve1. - Read the actual token balances of the contract via
IERC20(tokenX).balanceOf(address(this)). - Calculate
amount0andamount1by subtracting the stored reserves from the actual balances. This is what was deposited since the last update. - Call
_mintFee(_reserve0, _reserve1). Store the returned boolean asfeeOn. - Read
totalSupplyinto a local variable. This MUST happen after_mintFeebecause the fee mint may have changed totalSupply. - If
_totalSupply == 0: calculateliquidity = sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY, then mintMINIMUM_LIQUIDITYtoaddress(0). - If
_totalSupply > 0: calculateliquidity = min(amount0 * _totalSupply / _reserve0, amount1 * _totalSupply / _reserve1). - Require
liquidity > 0. A zero liquidity mint means the deposit was too small to represent any pool share. - Mint
liquidityLP tokens to thetoaddress. - Call
_update(balance0, balance1, _reserve0, _reserve1)to sync reserves and update the oracle. - If
feeOnis true, setkLast = uint(reserve0) * reserve1. This snapshot lets_mintFee()compute fee growth on the next call. - Emit the
Mintevent withamount0andamount1.