How Uniswap V2's Protocol Fee Works (LP Dilution, Not Per-Swap)
Uniswap V2's protocol fee is 1/6th of K growth, paid as LP token dilution at the next mint or burn. Why this design saves gas, and the math that drives it.
TL;DR
- Uniswap V2's protocol fee is 1/6th of the K-value growth between any two
mintorburncalls. - It is NOT charged per-swap. It accumulates as virtual LP token dilution and is minted to the fee recipient on the next
mintorburn. - This saves gas on every swap (no extra storage writes per trade) and makes the fee invisible to LPs in the moment.
- The math involves the square root of K growth divided by five, which produces the protocol's share. Get a single coefficient wrong and the protocol either over-takes from LPs or under-collects.
- The fee can be turned on or off by the
feeToaddress. By default in deployed Uniswap V2, it has been off; the LP fee is the entire 0.30% per swap.
Why this matters
The protocol fee is one of the more confusing parts of Uniswap V2 for builders forking the code. They see swap functions that take 0.30% and credit it directly to LPs. Then they see fee-related code in mint() and burn() and wonder if the protocol takes another cut on top.
It does, but only when feeTo is non-zero, and the mechanism is pull-based dilution rather than per-swap accounting. Understanding this lets you:
- Reason about what your fork's economics actually look like.
- Audit forks that claim to "redirect protocol fees" to make sure their math is correct.
- Make informed decisions about turning the protocol fee on for your own deployment.
The mechanism
Every Uniswap V2 swap takes a 0.30% input fee. Of that 0.30%:
- 5/6 (~0.25%) goes to LPs, by leaving the deducted tokens in the pool. The constant-product invariant
x * y >= kbecomesx' * y' >= k * 1.003after the swap, wherekis the pre-swap product. The LP token holders' shares are now backed by slightly more tokens. - 1/6 (~0.05%) accrues to the protocol, but is NOT immediately credited anywhere. It exists as accumulated K-growth.
The protocol's share is realized only when someone calls mint() or burn() on the pair. At that moment, the contract:
- Computes the K-growth between the last mint/burn and now.
- Calculates the LP token amount that represents 1/6 of that growth.
- Mints that amount of LP tokens to
feeTo. - Then proceeds with the user's mint or burn.
The fee recipient ends up with LP tokens that, when redeemed, give them their share of the pool's reserves, including the share that grew from the protocol fee.
The math
The function that computes the protocol's LP token mint is _mintFee(uint112 _reserve0, uint112 _reserve1):
function _mintFee(uint112 _reserve0, uint112 _reserve1) private returns (bool feeOn) {
address feeTo = IUniswapV2Factory(factory).feeTo();
feeOn = feeTo != address(0);
uint _kLast = kLast;
if (feeOn) {
if (_kLast != 0) {
uint rootK = Math.sqrt(uint(_reserve0).mul(_reserve1));
uint rootKLast = Math.sqrt(_kLast);
if (rootK > rootKLast) {
uint numerator = totalSupply.mul(rootK.sub(rootKLast));
uint denominator = rootK.mul(5).add(rootKLast);
uint liquidity = numerator / denominator;
if (liquidity > 0) _mint(feeTo, liquidity);
}
}
} else if (_kLast != 0) {
kLast = 0;
}
}
The numerator and denominator look mysterious at first. They are derived from the constraint that the protocol's new LP tokens should represent exactly 1/6 of the K growth (in terms of pool value).
Let S be the current LP supply, rootK be the current sqrt of K, and rootKLast be the sqrt of K at the last mint/burn. Define delta = rootK - rootKLast. The protocol's new mint f should satisfy:
f / (S + f) = (1/6) * (delta / rootK)
This is the fraction of the new total supply held by the protocol, equal to 1/6 of the proportional K-value growth. Solving for f:
6 * f * rootK = delta * (S + f)
6 * f * rootK = delta * S + delta * f
f * (6 * rootK - delta) = delta * S
f = (delta * S) / (6 * rootK - delta)
f = (delta * S) / (6 * rootK - rootK + rootKLast)
f = (delta * S) / (5 * rootK + rootKLast)
That's where the 5 comes from in the denominator. Off-by-one in this coefficient (using 4 or 6) gives the protocol either too much or too little.
The numerator is S * (rootK - rootKLast) which is totalSupply * (rootK - rootKLast) in code. The denominator is 5 * rootK + rootKLast which is rootK.mul(5).add(rootKLast) in code.
If you fork Uniswap V2 and tweak this formula (for example, to take 1/4 instead of 1/6), you have to redo the algebra. The coefficient is not a parameter you can swap for an arbitrary fraction without recomputing the denominator term.
Why dilution instead of per-swap accounting
The straightforward alternative is to take 0.05% from each swap and immediately transfer it to the fee recipient. Why doesn't Uniswap V2 do that?
Gas cost. Every swap would have one or two additional SSTORE operations (transferring the protocol's share). On Ethereum mainnet at a typical 50 gwei gas price, even a single extra SSTORE adds roughly $1-3 to the cost of every swap. Across millions of swaps per day, that's tens of thousands of dollars in burned gas to perform an accounting operation that could be deferred.
Atomicity with LP token math. The dilution approach makes the protocol's share automatically reflect any subsequent K growth between the swap and the eventual mint/burn that triggers _mintFee. The protocol gets paid in the same units (LP tokens) and at the same time scale as the LPs. No drift, no off-by-one between swap fees and rebalancing.
UX simplicity for LPs. When an LP redeems their share, the math is just share / totalSupply * reserves. The protocol's accumulated dilution is part of totalSupply, naturally diluting all LPs by the same proportion. No special accounting in burn() for "remove the protocol's pending fee first."
The cost of this design is conceptual complexity (the fee is invisible until it's claimed) and a corner case where kLast and the protocol's share are stale until the next mint/burn happens. For pools with frequent mints and burns, this gap is small. For pools with no liquidity activity for months, the protocol's share can accrue significantly before being realized.
When feeTo is zero
The deployed Uniswap V2 factory had feeTo == address(0) for years. This meant _mintFee ran but did nothing (the early return on feeOn = false). The full 0.30% per swap accrued to LPs.
When feeTo is zero, _mintFee also resets kLast = 0, which prevents the protocol from claiming retroactive fees if feeTo is ever set later. This is a deliberate choice: governance can turn the fee on going forward, but cannot reach back to claim past growth.
Common forks and modifications
"Redirect to treasury" forks
Some forks set feeTo to a treasury contract that receives the LP tokens directly. The math is unchanged; only the recipient differs.
"Change the share" forks
Some forks want a 1/4 or 1/3 protocol share instead of 1/6. As noted, this requires rederiving the denominator. The pattern:
- For share
1/N, the denominator becomes(N-1) * rootK + rootKLast. - For 1/4:
denominator = rootK.mul(3).add(rootKLast). - For 1/3:
denominator = rootK.mul(2).add(rootKLast).
If a fork claims to take "1/3" but uses Uniswap V2's original 5 * rootK + rootKLast, they're actually taking 1/6 regardless of intent. This kind of bug shows up in audits more often than people expect.
"Swap-time accounting" forks
Some forks reject the dilution approach entirely and credit fees per-swap. This is gas-expensive but gives clearer real-time accounting. The fees should NOT be added to reserve0 or reserve1 in this case (since those are still the pool's, not the protocol's), so a separate pendingProtocolFee0 and pendingProtocolFee1 storage is added. Auditors should check that swap math properly excludes these accumulated amounts.
Related questions
What is kLast in Uniswap V2? The product of reserves at the most recent mint or burn that involved _mintFee. It serves as the baseline for computing K growth on the next call.
If I never call mint or burn, does the protocol ever get paid? No. The fee accrues as K growth, but it is only realized when someone triggers _mintFee. Pools with no LP activity hold the protocol's share indefinitely.
Does the swap function call _mintFee? No. Only mint() and burn() call it. This is what makes the design gas-efficient on the swap path.
What's the relationship between kLast and the actual K? kLast is set to the current reserve0 * reserve1 at the END of every mint or burn (after the user's contribution and the protocol's mint, when feeOn). Between mints and burns, K grows due to swap fees, but kLast does not update.
Can the protocol's accumulated share be larger than the LP supply? No. The math constrains the protocol to 1/6 of K growth, expressed as a proportion of total supply. As long as 5 * rootK + rootKLast > 0, the mint amount is bounded.
When you rebuild it
Implementing _mintFee correctly is one of the trickier sections in the Uniswap V2 reconstruction. The test suite at Zealynx Academy includes coverage for:
- The full fee path with
feeToset, verifying the protocol's LP balance grows by the expected amount. - The fee path with
feeTozero, verifying no mint to address(0) and thatkLastresets. - The numerator/denominator math, verifying the 1/6 share against hand-computed expectations.
- The interaction with
_mint()ordering:_mintFeemust run BEFORE the user's_mint, otherwise the protocol's mint gets diluted by the user's subsequent mint.
That last point is its own subtle bug: get the ordering wrong, and the protocol silently loses a fraction of its fee on every mint. We covered that in a separate article on the _mintFee() ordering bug.
If you've made it through this far, you understand more about Uniswap V2's economics than 90% of people who fork it. The math is doing real work; it just looks like noise until you trace it.
Tagged