Found Academy useful? A $5 donation by May 14 helps us ship more, faster. Every donor counts (QF matching).

Donate
All articles
The BuildMay 7, 20269 min read

The _mintFee() Ordering Bug in Uniswap V2 Forks

In Uniswap V2's mint() and burn(), _mintFee() must run before reading totalSupply. Reverse the order and you silently dilute the protocol fee recipient on every liquidity event.

By Carlos (Bloqarl)

TL;DR

  • Uniswap V2's mint() and burn() both call _mintFee() once, then read totalSupply into a local variable.
  • That order is not optional. _mintFee() may mint LP tokens to the protocol fee recipient. If you read totalSupply first, your denominator is stale by exactly that mint.
  • The result is a silent over-allocation of LP tokens to the new depositor (or under-deduction from the burner), which steals value from the protocol fee recipient on every interaction.
  • The bug is invisible at the function level. The math compiles, the call works, the LP receives tokens. There is no revert and no event that surfaces the discrepancy.
  • It only manifests on forks. Production Uniswap V2 has the order right. The mistake happens when teams refactor the function, reorder the lines for readability, or reimplement from a whitepaper without referencing the source.

Why this matters

Almost every audit of a Uniswap V2 fork ends up reading mint() and burn() line by line. The reason is that these two functions touch every accounting invariant in the protocol: LP token supply, reserves, the K invariant, the protocol fee, and the price oracle. A bug in either one cascades through everything else.

Most of those audits find a fork that has rearranged the code. The variable names look slightly different. Comments have been added or removed. The _mintFee() call has been moved around to "make the code flow better". Sometimes the move is harmless. Sometimes it silently dilutes the fee recipient by a fraction of a percent on every interaction, and nobody notices because the fee recipient has no way to see what they should have received versus what they did receive.

If you operate a fork of Uniswap V2 and have ever wondered why your protocol fee accumulation looks lower than your fee growth model predicts, this is one of the first three things to check.

What _mintFee() actually does

Before the ordering rule makes sense, you need to know what _mintFee() is doing. The function is internal, called from mint() and burn(), and its job is to mint LP tokens to the protocol fee recipient based on the growth of the pool's K value since the last mint or burn.

The mechanism is described in the Uniswap V2 whitepaper. The protocol takes 1/6 of swap fees as a protocol fee, but rather than skim a tiny amount on every swap (which would cost gas and complicate accounting), Uniswap accumulates the protocol's claim implicitly via swap-driven K growth. When liquidity is next added or removed, _mintFee() realizes that claim by minting LP tokens to the feeTo address. Those LP tokens represent the protocol's accumulated share, redeemable like any other LP position.

The math is approximately:

rootK = sqrt(reserve0 * reserve1)
rootKLast = sqrt(kLast)
if rootK > rootKLast:
    numerator = totalSupply * (rootK - rootKLast)
    denominator = rootK * 5 + rootKLast
    liquidity = numerator / denominator
    if liquidity > 0:
        _mint(feeTo, liquidity)

The exact derivation is in section 2.4 of the whitepaper. What matters here is the side effect: when fees are on and K has grown, _mintFee() calls _mint(feeTo, liquidity), which increases totalSupply.

That increase is the payload of the ordering bug.

The correct order

Look at the actual Uniswap V2 source. In mint():

function mint(address to) external lock returns (uint liquidity) {
    (uint112 _reserve0, uint112 _reserve1, ) = getReserves();
    uint balance0 = IERC20(token0).balanceOf(address(this));
    uint balance1 = IERC20(token1).balanceOf(address(this));
    uint amount0 = balance0.sub(_reserve0);
    uint amount1 = balance1.sub(_reserve1);

    bool feeOn = _mintFee(_reserve0, _reserve1);
    uint _totalSupply = totalSupply; // gas savings, MUST be defined here
                                     // since totalSupply can update in _mintFee

    if (_totalSupply == 0) {
        liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY);
        _mint(address(0), MINIMUM_LIQUIDITY);
    } else {
        liquidity = Math.min(
            amount0.mul(_totalSupply) / _reserve0,
            amount1.mul(_totalSupply) / _reserve1
        );
    }
    require(liquidity > 0, 'UniswapV2: INSUFFICIENT_LIQUIDITY_MINTED');
    _mint(to, liquidity);
    // ...
}

The comment is in the original Uniswap source, written by the authors specifically because they expected this exact mistake. "uint _totalSupply = totalSupply; // gas savings, MUST be defined here since totalSupply can update in _mintFee".

Read those words again. The Uniswap V2 authors knew that some forks would inline this caching at the wrong line. They left the warning in the code. It is still ignored regularly.

What goes wrong if you swap the order

Suppose a fork rewrites mint() like this, intending to "group state reads at the top":

function mint(address to) external lock returns (uint liquidity) {
    (uint112 _reserve0, uint112 _reserve1, ) = getReserves();
    uint balance0 = IERC20(token0).balanceOf(address(this));
    uint balance1 = IERC20(token1).balanceOf(address(this));
    uint amount0 = balance0 - _reserve0;
    uint amount1 = balance1 - _reserve1;

    uint _totalSupply = totalSupply;       // <-- moved up
    bool feeOn = _mintFee(_reserve0, _reserve1);

    // ... rest of function
}

The function looks cleaner. The variable cache is alongside the other reads. Linters do not complain. Tests for the basic mint flow still pass.

But here is what happens in practice on a pool with feeOn == true and growing K:

  1. State at function entry: totalSupply = 100, K has grown 10% since last mint.
  2. _totalSupply is cached as 100.
  3. _mintFee() runs. It calculates that the protocol is owed roughly 0.33% of supply (a 10% K growth at 1/6 protocol fee, the math from the whitepaper). It calls _mint(feeTo, 0.33). Now totalSupply is 100.33 in storage.
  4. mint() continues using _totalSupply = 100.
  5. The liquidity formula liquidity = min(amount0 * 100 / reserve0, amount1 * 100 / reserve1) computes a slightly larger LP token amount than it should. The correct denominator was 100.33.
  6. The depositor receives those extra LP tokens. _mint(to, liquidity) runs. Now totalSupply is correct because it was incremented from the storage value.
  7. The depositor walks away with LP tokens that proportionally come at the protocol fee recipient's expense. The recipient still got their _mintFee() mint, but its share of total supply is smaller than it should be.

The dilution is small per interaction. On a fork with millions in TVL and hundreds of mint/burn events per day, it adds up to material lost protocol revenue.

Why this is invisible

The ordering bug has properties that make it especially hard to spot:

  • No revert. Every line executes successfully. There is no require() that catches the dilution.
  • No anomalous event. The Mint event fires with the LP amount the depositor expected (because they got slightly more than they should have, not less).
  • No off-chain alarm. The protocol fee recipient just sees their LP balance growing slowly. They have no reference for "how much was I supposed to get".
  • Doesn't affect end-user funds. The user who called mint() got more LP tokens than they should have, but that's not their problem. They are not motivated to file an issue.

The discrepancy is between the protocol fee recipient and the depositor, and the protocol fee recipient is usually a smart contract or treasury that does not actively reconcile. The bug can sit in a fork for years.

How to detect it

There are three ways to find this bug in a fork audit.

1. Read the source order. This is the obvious one. In mint() and burn(), the call to _mintFee() must come before the _totalSupply = totalSupply assignment. If they are reversed, write a finding.

2. Compare event sequencing in a fee-on simulation. Set up a hardhat fork of the protocol with feeTo set to a known address. Add liquidity once to seed the pool. Run swaps until K has grown a measurable amount. Then call mint() with a known deposit. Inspect the LP token balances of feeTo and the depositor. Compute what each should have gotten under the correct math. If the numbers do not match, the order is wrong.

3. Use differential testing against the canonical source. Run the same input sequence through the original Uniswap V2 contract and the fork. Compare LP token balances afterward. Any drift reveals the ordering bug (and any other accounting drift, which is why this method is more general).

In Zealynx Academy's reconstruction module, the test suite catches the inverted order automatically. The Section 5 tests deposit liquidity into a pre-warmed pool with feeTo set, run swaps to grow K, and then assert the relative LP balances of the new depositor and the fee recipient. A fork that swaps the order fails the test on the second mint.

The other side: burn() has the same constraint

The mistake is symmetric for burn(). Same lines, same caching, same potential to read totalSupply before _mintFee() mutates it. If the order is wrong in burn(), the LP who is exiting the pool gets less of a withdrawal than they should, and that delta accrues to the fee recipient (the opposite direction from the mint() bug).

In practice, the burn() version of this bug is less visible because the LP exiting the pool typically does not reconcile their expected withdrawal versus their actual one to the wei. The dilution still happens, just in the opposite direction.

Related questions

Why does _mintFee() mutate totalSupply at all? Because that is the mechanism by which the protocol claims its share of accumulated fees. Rather than transferring underlying tokens, the protocol mints itself LP tokens, which can be redeemed later. The mint increases supply.

What if I disable protocol fees? With feeTo == address(0), _mintFee() is a no-op. The feeOn boolean returned is false, no LP tokens are minted to the fee recipient, and totalSupply does not change. In that case the ordering does not matter for correctness. But you should still leave the order correct, because any future flip of feeTo to a real address would activate the bug retroactively on every subsequent mint and burn.

Is the bug a security issue or just a fee-leak? It is a value-leak from the protocol fee recipient to depositors. Whether it qualifies as "security" depends on your definition. It is not a fund loss for users. It is a dilution of the protocol's own accumulated revenue. Audit reports tend to flag it as Medium or Low severity, depending on how much TVL and fee accrual the protocol expects.

What's the simplest fix? Move the _totalSupply = totalSupply assignment to immediately after the _mintFee() call. That's it. One line moved. The comment in Uniswap V2's source is the documentation.

Where to see it in the source

The two functions live in UniswapV2Pair.sol at lines around 130 and 160 in the original deployment. The pattern is identical:

bool feeOn = _mintFee(_reserve0, _reserve1);
uint _totalSupply = totalSupply; // <-- this line, this position

If you find yourself reading a fork where these two lines are in the opposite order, you have found a finding. Write it up.

When you rebuild the pair contract in Zealynx Academy, the build module's test suite specifically asserts the relative LP balances after a fee-active mint. You cannot pass with the order inverted.

Tagged

Uniswap V2Smart Contract SecurityDeFi