DEV Community

Cover image for From Intent to LP Tokens: The Uniswap V2 Mint Process
Adi
Adi

Posted on

From Intent to LP Tokens: The Uniswap V2 Mint Process

If you've been following along, we've covered what AMMs are and how the constant product formula works, then took a detour to map out the contract architecture before diving in. This is the third post in the series, and the first one where we actually get into a flow end to end. We're starting with Mint — adding liquidity to a pool.

In practice, this is where a lot of DeFi begins. Liquidity providers deposit tokens into pools and earn a share of the fees generated by swaps. Protocols build on top of this — routing trades, sourcing prices, or deploying their own liquidity as part of larger strategies. A surprising amount of “yield” in the ecosystem traces back to this one operation.

The full path for this operation, as a reminder from the map post: User → Router → Pair → User. That's the skeleton. Everything below is what happens inside each of those steps.


Starting at the Router: addLiquidity()

Everything starts when a user calls addLiquidity() on UniswapV2Router02. The function signature looks like this:

addLiquidity(address tokenA, address tokenB, uint amountADesired, uint amountBDesired, uint amountAMin, uint amountBMin, address to, uint deadline)
Enter fullscreen mode Exit fullscreen mode

Walking through the parameters: tokenA and tokenB are the two tokens you want to deposit into. amountADesired and amountBDesired are the amounts you'd like to put in (your intention). amountAMin and amountBMin are the minimums you're willing to accept (your floor). to is the address that will receive the LP tokens. deadline is a timestamp after which the transaction should revert rather than execute, which prevents your transaction from sitting in the mempool and executing at a price you no longer want.

The reason there are both "desired" and "minimum" amounts will become clear in a moment.


The ratio problem: _addLiquidity()

Before anything gets transferred anywhere, the Router calls its own internal function _addLiquidity() to figure out the exact amounts that should actually go into the pool. This is where the ratio enforcement happens.

Remember from the first post that the spot price of a pool is just reserveB / reserveA — the ratio of the two balances. That ratio is the price. If you could just deposit any arbitrary amounts of both tokens, you'd be changing the ratio, which means you'd be changing the price. Adding liquidity shouldn't move the price. So the deposits have to respect the existing ratio.

_addLiquidity() first calls the Factory to get the pool address for the pair. If the pool doesn't exist yet, the Factory deploys one. Then it fetches the current reserves reserveA and reserveB. Two paths from here:

  • If this is a brand new pool with zero reserves, there's no existing ratio to preserve. Your amountADesired and amountBDesired get returned as-is. Whatever you put in first sets the initial price, which is exactly why first-deposit pricing is something to be intentional about.
  • If the pool already exists, the Router calls quote() on UniswapV2Library, passing your amountADesired alongside the current reserveA and reserveB. The formula is straightforward:

amountB=amountA×reserveBreserveA amountB = amountA \times \frac{reserveB}{reserveA}

Rearranged to show what it's actually enforcing:

amountBamountA=reserveBreserveA \frac{amountB}{amountA} = \frac{reserveB}{reserveA}

Your deposit ratio must equal the existing pool ratio. Same logic as the constant product intuition from the first post: the ratio of the pool is the price, and you're not allowed to move it just by adding liquidity.

The result of quote() is called amountBOptimal (the amount of B that must accompany your desired amount of A). Now there's a check:

  • If amountBOptimal ≤ amountBDesired, you have enough B and you're not being asked for more than you offered. The function returns amountADesired and amountBOptimal as the final deposit amounts.
  • If amountBOptimal > amountBDesired, your B is the limiting factor. The function flips it: call quote() again treating amountBDesired as the anchor, calculating the optimal A to go alongside it. The returned pair becomes amountAOptimal and amountBDesired.

This is where amountAMin and amountBMin come in. After working out the optimal amounts, the Router checks that neither falls below the user's stated floor. If the pool's ratio has moved since the user submitted the transaction (because someone else swapped in the meantime) and the optimal amounts have shifted below the minimums, the transaction reverts. Slippage protection, applied to liquidity provision.


Sending the tokens: safeTransferFrom()

Once _addLiquidity() returns the final amountA and amountB, the Router needs the pool's address. Since the Factory uses CREATE2 for deterministic deployment (covered in the previous post), the Router can compute the pair address using pairFor() on UniswapV2Library without an external call, just a hash.

Then it transfers both tokens from the user directly to the pair contract using safeTransferFrom() from TransferHelper.

Two things are worth unpacking here. First, why safeTransferFrom() and not a plain transfer? The architecture post touched on the non-returning token problem, but there's a deeper reason the Router is the one initiating this transfer at all rather than asking the user to send tokens themselves.

In smart contract security, there's a strong preference for pulling funds over pushing them. Pushing means the user sends tokens directly to a contract before calling it. Pulling means the contract pulls the tokens in as part of its own execution, after being given approval to do so. The practical difference matters: if you push first and the transaction fails halfway through, your tokens are already sitting in the contract with nothing to claim them. With a pull pattern, the transfer and the logic happen atomically inside a single call. Either both succeed or neither does.

There's also reentrancy to consider. Reentrancy is when an external contract call made during your function's execution triggers a callback back into your function before it has finished, letting an attacker exploit the in-between state. Pushing funds from outside the call opens a window where state and balances can be inconsistent in ways that reentrancy can exploit. Pulling inside a controlled execution, combined with a reentrancy lock (which the pair contract uses via its lock modifier), keeps that window closed. The general rule in web3: avoid letting funds sit unaccounted in a contract whenever possible, and never push when you can pull.

Second, why safeTransferFrom() specifically rather than the standard ERC-20 transferFrom()? As covered in the architecture post, certain tokens (USDT being the most notorious) don't return a boolean from transfer() even though the ERC-20 spec says they should. A naive require(token.transferFrom(...)) would revert on these tokens even when the transfer succeeded. TransferHelper.safeTransferFrom() handles both cases gracefully by using a low-level call and checking the result regardless of whether a return value was provided.


Inside the Pair: mint()

With the tokens sitting in the pair contract, the Router calls mint(to) on UniswapV2Pair. This is where LP tokens get minted and handed to the user. The function does several things in sequence.

_mintFee()

The very first thing mint() does is call _mintFee(_reserve0, _reserve1). This is the protocol fee mechanism, and it's worth understanding separately before the rest of mint() makes full sense.

Uniswap V2 has a protocol fee switch. When enabled by governance, 1/6th of the 0.3% swap fee (so 0.05% of every trade) is redirectable to a feeTo address set in the Factory. The other 5/6ths always go to LPs.

The clever thing about how this is implemented: the protocol fee isn't collected on every swap, because that would add gas cost to every single trade. Instead, it's calculated lazily — only when someone adds or removes liquidity. _mintFee() figures out how much the pool has grown from swap fees since the last liquidity event, and mints LP tokens to the feeTo address representing that 1/6th cut.

The mechanism uses kLast, a value stored in the pair contract representing reserve0 × reserve1 at the time of the last liquidity event. Liquidity events here means mint or burn only. Swaps deliberately do not update kLast. That distinction is the entire point: between two liquidity events, the only thing affecting reserves is swaps, and every swap deposits a small fee into the pool. So the gap between kLast and the current reserve0 × reserve1 represents exactly the fees accumulated from trading activity since the last time anyone added or removed liquidity.

But wait. Wasn't k supposed to stay constant? That was the whole point of the constant product formula.

Yes and no. The invariant check during a swap ensures that k does not decrease as a result of the swap itself. But the 0.3% fee that stays in the pool after each swap means k actually grows slightly with every trade. The invariant is a floor, not a ceiling. Each swap pushes the pool to a new, slightly higher value of reserve0 × reserve1. kLast captures what that product was at the last liquidity event, and reserve0 × reserve1 now is larger because of all the fees collected in between. The difference between them is the accumulated fee, expressed as growth in k.

Now, why measure this as growth in sqrt(k) rather than growth in k directly? Because LP token supply is proportional to sqrt(k) (recall from the fresh pool formula: liquidity = sqrt(amount0 × amount1)). To talk about what share of the pool someone is entitled to, you need to work in the same units as LP tokens. sqrt(k) is that unit.

Increase in k due to swaps, subsequently adding fee

This is what half an hour of wrestling an AI, given the current RAM prices, produces. I know it’s not only not perfect, its downright terrible. But the only thing that matters is this: fees show up as growth in √k.

Let rootK = sqrt(reserve0 × reserve1) now and rootKLast = sqrt(kLast) from the last liquidity event. The growth rootK - rootKLast is the liquidity added to the pool purely from fees. The protocol wants 1/6th of that.

Here's why you can't just compute (rootK - rootKLast) / 6 and call it done. The protocol doesn't receive tokens directly. It receives LP tokens, which are a claim on the pool. Minting new LP tokens dilutes the existing ones. So you need to figure out: how many new LP tokens, when added to the existing supply, would entitle their holder to exactly 1/6th of the fee growth?

Let's call the LP tokens to mint s. After minting, the new total supply is totalSupply + s. The protocol's share of the pool would be s / (totalSupply + s). That share, multiplied by the current pool size rootK, must equal 1/6th of the fee growth:

stotalSupply+s×rootK=16×(rootKrootKLast) \frac{s}{totalSupply + s} \times rootK = \frac{1}{6} \times (rootK - rootKLast)

Solving for s:

s=totalSupply×(rootKrootKLast)5×rootK+rootKLast s = \frac{totalSupply \times (rootK - rootKLast)}{5 \times rootK + rootKLast}

This is exactly the formula in the contract. The 5 × rootK + rootKLast denominator isn't arbitrary: it falls out of the algebra when you solve for s while ensuring the protocol gets 1/6th and not a wei more. Working through the algebra yourself is worth doing once.

If feeTo is the zero address (protocol fee is off, which is the default), _mintFee() skips all of this and returns false.

Reserves vs balances

Back in mint(), the function reads two sets of values:

  • _reserve0 and _reserve1: the last recorded balances, what the contract remembered from its previous interaction
  • balance0 and balance1: the actual current token balances sitting in the contract right now

These are different because the Router just sent tokens to the pair via a plain ERC-20 transfer. That updated the token contract's ledger but since the pair hasn't processed it yet, the balances just sit there idly in the contract unaccounted for. The difference is what the user just deposited:

amount0 = balance0 - _reserve0
amount1 = balance1 - _reserve1
Enter fullscreen mode Exit fullscreen mode

LP token calculation

With amount0 and amount1 known, the contract works out how many LP tokens to mint. The approach splits based on whether this pool has ever had liquidity.

Fresh pool (totalSupply == 0):

liquidity=amount0×amount1MINIMUMLIQUIDITY liquidity = \sqrt{amount0 \times amount1} - MINIMUM_LIQUIDITY

The geometric mean of the two deposits. This works better than a simple sum because it treats both tokens symmetrically: if you swap which token is token0 and which is token1, the geometric mean gives the same result. It also scales sensibly: a pool with 10x the deposits produces 10x the LP tokens.

The MINIMUM_LIQUIDITY (1000 LP tokens, hardcoded) gets minted to the zero address instead of the user, permanently locked. This one-time burn ensures the pool can never be fully emptied — there will always be at least 1000 shares outstanding, meaning the pool always holds some non-zero amount of both tokens. Without this, a malicious first depositor could manipulate the ratio so severely after draining the pool that future LPs get griefed (there are several attack vectors that come under the first deposit attack category. We’ll cover these separately someday). 1000 shares is economically negligible at 10^-18 token precision.

Existing pool (totalSupply > 0):

liquidity=min(amount0×totalSupplyreserve0, amount1×totalSupplyreserve1) liquidity = \min\left( \frac{amount0 \times totalSupply}{reserve0},\ \frac{amount1 \times totalSupply}{reserve1} \right)

Each token independently says: given how much I deposited relative to what's already here, I'm entitled to this share of the pool. The min() of the two is taken. Deposit in exactly the right ratio and both values are equal, no penalty. Deposit in the wrong ratio — too much token0 relative to what the pool holds — and the token1 side gives fewer LP tokens. The excess token0 gets absorbed into the pool with no compensation. The min() is what silently enforces ratio compliance. Deposit sloppily and you're effectively donating to everyone else in the pool.

_mint()

With liquidity calculated, the pair calls _mint(to, liquidity), the standard ERC-20 mint inherited from UniswapV2ERC20. totalSupply increases by liquidity and the to address receives that many LP tokens.

_update()

_update(balance0, balance1, _reserve0, _reserve1) syncs the stored reserves to the current actual balances. New reserve0 becomes balance0, new reserve1 becomes balance1.

But _update() also does something less obvious, connecting back to the TWAP oracle mention in the architecture post. If any time has elapsed since the last block that interacted with this pool, it updates the cumulative price accumulators:

price0CumulativeLast += (reserve1 / reserve0) × timeElapsed
price1CumulativeLast += (reserve0 / reserve1) × timeElapsed
Enter fullscreen mode Exit fullscreen mode

The price at the old reserves, multiplied by how long those reserves were in effect, gets added to a running total. An external oracle contract can snapshot these accumulators at two points in time and divide by elapsed time to get a time-weighted average price (a TWAP) that's much harder to manipulate than a spot price reading. This accumulation happens silently inside every _update() call, whether anyone is using the oracle or not. TWAP will be covered in a separate post entirely.

kLast and the Mint event

After _update(), if _mintFee() returned true (protocol fee is on), kLast gets updated to reserve0 × reserve1 using the freshly synced values. This records the new baseline for the next fee calculation.

Finally, the Mint event is emitted and the function returns.


To summarise what actually happened across the full flow: the user called the Router, which calculated the right amounts to maintain the pool's existing price ratio, pulled the tokens into the pair, and then the pair figured out the fair number of LP tokens to mint. It accounted for any protocol fee owed, computed the geometric mean for a new pool or the proportional min for an existing one, minted the tokens, and quietly updated the price accumulators in the background.

Next up: Swap, where the constant product formula stops being theory and the fee math actually runs live.

Top comments (0)