Section 7 of 16
Pair: The Swap
Key takeaway: Uniswap V2's
swap()uses an optimistic-transfer pattern: tokens leave the pair before any input is verified, the optionalIUniswapV2Calleecallback fires (enabling flash swaps with no flash-loan-specific code), and only at the end does the contract enforce the constant-product invariantK' >= Kagainst the actual final balances. A reentrancylockmodifier prevents recursive calls._mintFee()reuses the LP-token-dilution mechanism (1/6 of K growth) to realize the protocol's accumulated fee without per-swap accounting overhead.
What You Are Building
The swap() function is the heart of the protocol. Every trade on Uniswap V2 goes through this function. It also enables flash loans without any special flash loan code. You will also implement _mintFee(), which handles protocol fee collection through LP token dilution. Together, these two functions complete the UniswapV2Pair contract. This section is the most important one in the entire module. Take your time with it.
The Optimistic Transfer Pattern
The swap function's design is counterintuitive. It takes output amounts as parameters, transfers those tokens to the recipient before verifying any input, and only checks the invariant at the very end. Tokens leave the contract before the contract knows whether it will receive anything in return.
This is not a bug. It is a deliberate design pattern called "optimistic transfer," and it is what makes Uniswap V2's flash loans possible without a single line of flash loan specific code.
Here is the logic: the function does not care how the invariant is satisfied, only that it is satisfied by the time execution reaches the final check. The caller might have sent input tokens to the pair before calling swap (the normal Router flow). The caller might receive the output tokens, use them for arbitrage or liquidation, and return tokens in the callback (a flash loan). The caller might even do both. The swap function is indifferent. It sends tokens out, optionally calls back, reads the final balances, and enforces the math.
This is a fundamentally different paradigm from "transfer in, validate, transfer out." By deferring validation to the end, the function becomes maximally flexible. One function serves as both a swap and a flash loan facility.
The Swap Flow, Step by Step
1. Validate Outputs
require(amount0Out > 0 || amount1Out > 0, 'UniswapV2: INSUFFICIENT_OUTPUT_AMOUNT');
At least one output must be positive. Calling swap with both outputs as zero would be a no op that wastes gas.
2. Check Against Reserves
require(amount0Out < _reserve0 && amount1Out < _reserve1, 'UniswapV2: INSUFFICIENT_LIQUIDITY');
You cannot withdraw more than the pool holds. Note the strict less than: withdrawing exactly the full reserve would leave zero liquidity and break the constant product formula (division by zero in price calculations).
3. Optimistic Transfer
if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out);
if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out);
Tokens are sent to the recipient now. No input has been verified. If you are reading this and thinking "that cannot be safe," keep reading. The invariant check at step 7 is the safety net.
4. Flash Loan Callback
if (data.length > 0) IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data);
If the caller passed non empty data, the pair calls uniswapV2Call on the recipient address. This is the flash loan hook. The recipient now has the output tokens and can do whatever it wants: arbitrage across DEXes, liquidate an undercollateralized position on Aave, refinance a Maker vault. The only constraint is that by the time this callback returns, enough tokens must have been sent back to the pair to satisfy the invariant.
The callback receives msg.sender (who initiated the swap, usually the Router or an EOA), the two output amounts, and the arbitrary data bytes. Contracts that want to use flash loans implement the IUniswapV2Callee interface. Contracts that do not use flash loans simply pass empty data and this branch is skipped.
5. Read New Balances
uint balance0 = IERC20(_token0).balanceOf(address(this));
uint balance1 = IERC20(_token1).balanceOf(address(this));
After the transfers and callback, the function reads the actual token balances. Not the stored reserves. The actual balanceOf. This captures any tokens that were sent to the pair during the callback or before the swap was called.
6. Calculate Input Amounts
uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;
uint amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0;
require(amount0In > 0 || amount1In > 0, 'UniswapV2: INSUFFICIENT_INPUT_AMOUNT');
Walk through this with concrete numbers. Suppose reserve0 is 1000 and amount0Out is 50. Then _reserve0 - amount0Out = 950. This is what the balance would be if no new tokens arrived. If the actual balance is 1100, then amount0In = 1100 - 950 = 150. The pair received 150 tokens of token0 as input.
If the balance equals or is below the expected post withdrawal amount, amountIn is 0 for that token. At least one input must be positive, otherwise the swap is receiving tokens for free.
7. The Fee Adjusted K Invariant
This is the core safety check:
uint balance0Adjusted = balance0 * 1000 - amount0In * 3;
uint balance1Adjusted = balance1 * 1000 - amount1In * 3;
require(balance0Adjusted * balance1Adjusted >= uint(_reserve0) * uint(_reserve1) * 1000000, 'UniswapV2: K');
The textbook constant product formula is x * y = k. Uniswap V2 adds a 0.3% fee, which means 0.3% of every input amount is excluded from the effective balance. The math avoids floating point by scaling everything by 1000.
Here is the derivation. The fee is 0.3%, so the "effective" input is amountIn * 0.997. In integer math: amountIn * 997 / 1000. The adjusted balance (what the pool "counts" toward the invariant) is balance - amountIn * 0.003, which is balance - amountIn * 3 / 1000. To avoid division, multiply both sides by 1000: balance * 1000 - amountIn * 3. The right side of the inequality becomes reserve0 * reserve1 * 1000 * 1000 = reserve0 * reserve1 * 1000000.
Why >= instead of ==? Because K grows with every swap. The 0.3% fee stays in the pool as additional reserves, which increases K. So after a swap, the new K is always slightly larger than the old K. The check ensures K never decreases. If someone tried to extract more tokens than the fee adjusted math allows, the new K would be smaller than the old K, and the transaction reverts.
This is the only security check that protects the pool's funds. Every token in the pool is guarded by this single inequality.
8. Update and Emit
_update(balance0, balance1, _reserve0, _reserve1);
emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to);
Store the new reserves (via _update, which also handles the TWAP oracle) and emit the event for indexers.
The _mintFee() Function: Protocol Fee Collection
Uniswap V2 has an optional protocol fee. When the governance designated feeTo address is set (not address zero), 1/6th of the 0.3% trading fee (0.05%) goes to the protocol. The remaining 5/6ths (0.25%) stays with liquidity providers.
The protocol fee is not collected on every swap. That would be gas expensive. Instead, it is collected lazily: only during mint() and burn() calls, by minting new LP tokens to the feeTo address.
How It Works
The pair stores kLast, the value of reserve0 * reserve1 at the time of the last mint or burn. Between mints and burns, K grows from swap fees. The difference between the current K and kLast represents accumulated fees. The protocol takes 1/6th of that growth.
uint rootK = Math.sqrt(uint(_reserve0) * uint(_reserve1));
uint rootKLast = Math.sqrt(_kLast);
We compare square roots of K rather than K itself. This is because LP token value is proportional to sqrt(K), not K directly. (Recall from the mint section: liquidity = sqrt(x * y) for the first deposit.)
The 1/6th Math, Derived
If the protocol should receive 1/6th of fee growth, how many LP tokens should be minted? Let S be totalSupply, rootK the current sqrt(K), and rootKLast the previous sqrt(K).
The growth factor is rootK / rootKLast. The protocol's share is 1/6th of this growth. The formula for LP tokens to mint is:
numerator = S * (rootK - rootKLast)
denominator = rootK * 5 + rootKLast
liquidity = numerator / denominator
Why rootK * 5? This comes from the algebra of dilution. After minting liquidity tokens to feeTo, the new totalSupply is S + liquidity. The protocol's share of the pool is liquidity / (S + liquidity). Setting this equal to 1/6 * (rootK - rootKLast) / rootK and solving for liquidity yields exactly the formula above, with the 5 in the denominator emerging from the 1/6 split (6 - 1 = 5).
Walk Through With Numbers
Suppose totalSupply = 1000, rootKLast = 100, rootK = 106 (K grew 6% from swap fees).
numerator = 1000 * (106 - 100) = 6000
denominator = 106 * 5 + 100 = 630
liquidity = 6000 / 630 = 9 (integer division)
9 LP tokens are minted to feeTo. The protocol now holds 9/1009 = 0.89% of the pool. The fee growth was 6%, and 1/6th of 6% is 1%, so 0.89% is close (the small difference is rounding from integer division).
When the Protocol Fee Is Disabled
If feeTo is address(0), the protocol fee is off. In this case, _mintFee does two things: it checks if kLast is nonzero, and if so, sets it to zero. Setting kLast to zero clears the previous fee checkpoint so that, if protocol fees are later re-enabled, fee calculation restarts from a fresh baseline and does not include growth that occurred while fees were off. It also ensures future _mintFee calls skip the fee-minting branch until a new kLast is established, avoiding unnecessary computation. The function returns false to signal that kLast should not be updated after this mint/burn.
When the protocol fee is enabled, _mintFee returns true, and the calling function (mint or burn) writes the new K value to kLast. This snapshot becomes the baseline for the next fee collection.
Flash Loans in Detail
The flash loan mechanism deserves special attention because of how implicit it is. There is no flashLoan() function. There is no flash loan fee parameter. Flash loans are an emergent property of the optimistic transfer design.
To execute a flash loan, a contract calls swap() with output amounts specifying how many tokens to borrow, its own address as the recipient, and non empty data. The pair sends the tokens, then calls uniswapV2Call on the borrower. Inside this callback, the borrower uses the tokens however it wants, then transfers enough tokens back to the pair to pass the K check. The "fee" for the flash loan is the same 0.3% trading fee, because the K invariant enforces it.
A flash loan for 1000 USDC from a WETH/USDC pool would require returning approximately 1003 USDC (or the equivalent value in WETH) before the callback returns. The borrower can also return a combination of both tokens, as long as the adjusted K inequality holds.
Your Task
This is the biggest section. You need to implement two functions:
- swap(): The complete swap logic with all validations, optimistic transfers, optional callback, input calculation, fee adjusted K check, and reserve update.
- _mintFee(): Protocol fee calculation and LP token minting.
Take it step by step. The swap has many moving parts, but each step is straightforward on its own.