Share on XShare on LinkedInShare on Telegram
Auditing

Quill Findings: Race Condition in LP Fee Splits

Learn how two permissionless calls let a token creator collect the whole LP fee backlog at a stale, higher share, and how to fix this race condition.

Author
QuillAudits Team
•October 26, 2026
Quill Findings: Race Condition in LP Fee Splits
Share on XShare on LinkedInShare on Telegram

This is the sixth blog in our series Quill Findings, where we share interesting vulnerabilities our auditors found during client audits, for auditor and developer education. To read our previous Quill Findings blogs, check here.

Today's finding comes from a token launchpad built on Uniswap v4 (@Uniswap). We found that the split of LP trading fees between the token creator and the protocol depends on the order of two calls that anyone can make.

The token creator can use that order to collect the whole fee backlog at their old, higher share.

How the fee split works

When a token graduates on this launchpad, its liquidity moves into a Uniswap v4 pool. The LP position is locked in a contract called LPLocker, and it keeps earning trading fees from then on.

Those fees are shared between the token creator and the protocol.

The creator's share isn't fixed. As the token's FDV (fully diluted valuation) grows past set thresholds, the creator's share steps down. A contract called LPFeeRatchet tracks this.

So for a successful launch, the creator earns a bigger share of fees early on and a smaller share once the token has grown.

Technical breakdown

Three contracts take part in every fee payout.

Fees sitting in the LP position are harvested with collect(). Anyone can call it.

1// LPLocker.sol
2function collect(uint256 tokenId) external {
3    // anyone can call this
4    uint256 fees = _collectFees(tokenId);
5
6    // fees are sent to the splitter as LP fees
7    splitter.depositETH{value: fees}(launchToken, Stream.LP_FEES);
8}
9

The splitter then decides how much of that goes to the creator. It reads the creator's share at the moment the fees arrive.

1// TokenSplitter.sol
2function depositETH(address launchToken, Stream stream) external payable {
3    // the share is read right now, at deposit time,
4    // not when the fees were actually earned
5    uint256 bps = lpCreatorBpsOf(launchToken);
6
7    uint256 toCreator = msg.value * bps / 10_000;
8    // ... the rest goes to the protocol side
9}
10

The share itself only changes when someone calls poke(). Anyone can call this too.

1// LPFeeRatchet.sol
2function poke(address launchToken) external {
3    // once FDV passes the next threshold, the creator's share drops
4    if (fdv(launchToken) >= nextThreshold[launchToken]) {
5        creatorBps[launchToken] = lowerTier;
6    }
7}
8

This is where the race comes from. collect() moves the fees, and poke() updates the share. The contracts treat them as two separate calls and don't care which one runs first.

If collect() lands first, every fee waiting in the position is split at the old share. If poke() lands first, the same fees are split at the new one.

Neither result reflects when the fees were actually earned.

How the creator uses it

The creator has the strongest reason to care about the order, so they can run a bot to take advantage of it.

1// graduation   fees start piling up in the LP position
2//   ...        trading continues, the fee backlog keeps growing
3//
4// FDV rises    the next threshold is crossed
5//              nobody has called poke() yet
6//
7// block M      the creator's bot calls collect()
8//              the whole backlog is split at the old, higher share
9//
10// block M+1    poke() finally runs and the share drops,
11//              but the backlog has already been paid out
12

The bot only needs to watch FDV and send collect() before anyone sends poke().

Why it matters

The size of the fee pool is what makes this serious.

Neither collect() nor poke() pays the caller anything. So outside parties have little reason to call collect() regularly, or to poke() the moment a threshold is crossed.

That lets fees build up from graduation onward. For a successful launch, this backlog is the largest pile of fees it will ever earn.

The whole backlog gets priced at whichever share is active when collect() finally runs, and the creator is the one choosing that moment. Whatever extra the creator receives comes straight out of the protocol's side of the split.

Proof of concept

This is a minimal example to show the bug from the code side. It isn't the client's contract. Uniswap v4 fee accrual and the FDV price feed are replaced with simple stand-ins, so the ordering issue is easy to see.

In the test, the creator starts at a 50% share, which drops to 20% once FDV passes the threshold. The LP position earns 40 ETH of fees below the threshold and another 60 ETH above it, and nobody calls poke() in between.

The test then runs both orders from the same starting point and compares the result.

Set up a Foundry project:

1mkdir fee-poc && cd fee-poc
2forge init --no-git .
3rm src/Counter.sol test/Counter.t.sol script/Counter.s.sol
4

src/LPFees.sol:

1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.24;
3
4/// @notice Minimal reproduction of the launchpad's LP fee flow. Not the
5/// audited source. Uniswap v4 fee accrual and the FDV price feed are
6/// replaced with simple stand-ins so the ordering issue is easy to see.
7
8contract LPFeeRatchet {
9    uint256 public constant HIGH_TIER = 5_000; // 50% to the creator
10    uint256 public constant LOW_TIER = 2_000;  // 20% to the creator
11    uint256 public constant FDV_THRESHOLD = 10_000_000e18;
12
13    mapping(address => uint256) public creatorBps;
14    mapping(address => uint256) public fdv; // stand-in for the pool price
15
16    function initLaunch(address launchToken) external {
17        creatorBps[launchToken] = HIGH_TIER;
18    }
19
20    function setFdv(address launchToken, uint256 value) external {
21        fdv[launchToken] = value;
22    }
23
24    /// @notice Anyone can call this. Once FDV passes the threshold,
25    /// the creator's share steps down.
26    function poke(address launchToken) external {
27        if (fdv[launchToken] >= FDV_THRESHOLD && creatorBps[launchToken] == HIGH_TIER) {
28            creatorBps[launchToken] = LOW_TIER;
29        }
30    }
31
32    function lpCreatorBpsOf(address launchToken) external view returns (uint256) {
33        return creatorBps[launchToken];
34    }
35}
36
37contract TokenSplitter {
38    enum Stream { LP_FEES }
39
40    LPFeeRatchet public immutable ratchet;
41    address public immutable creator;
42    address public immutable protocol;
43    mapping(address => uint256) public owed;
44
45    constructor(LPFeeRatchet _ratchet, address _creator, address _protocol) {
46        ratchet = _ratchet;
47        creator = _creator;
48        protocol = _protocol;
49    }
50
51    function depositETH(address launchToken, Stream) external payable {
52        // the share is read right now, at deposit time,
53        // not when the fees were actually earned
54        uint256 bps = ratchet.lpCreatorBpsOf(launchToken);
55
56        uint256 toCreator = msg.value * bps / 10_000;
57        owed[creator] += toCreator;
58        owed[protocol] += msg.value - toCreator;
59    }
60}
61
62contract LPLocker {
63    TokenSplitter public immutable splitter;
64    address public immutable launchToken;
65
66    constructor(TokenSplitter _splitter, address _launchToken) {
67        splitter = _splitter;
68        launchToken = _launchToken;
69    }
70
71    /// @notice Stand-in for trading fees piling up in the Uniswap v4 position.
72    function accrueFees() external payable {}
73
74    /// @notice Anyone can call this. Sends every pending fee to the splitter.
75    function collect(uint256 /* tokenId */) external {
76        uint256 fees = address(this).balance;
77        splitter.depositETH{value: fees}(launchToken, TokenSplitter.Stream.LP_FEES);
78    }
79}
80

test/FeeRace.t.sol:

1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.24;
3
4import {Test, console2} from "forge-std/Test.sol";
5import {LPFeeRatchet, TokenSplitter, LPLocker} from "../src/LPFees.sol";
6
7contract FeeRaceTest is Test {
8    address launchToken = makeAddr("launchToken");
9    address creator = makeAddr("creator");
10    address protocol = makeAddr("protocol");
11
12    LPFeeRatchet ratchet;
13    TokenSplitter splitter;
14    LPLocker locker;
15
16    function setUp() public {
17        ratchet = new LPFeeRatchet();
18        splitter = new TokenSplitter(ratchet, creator, protocol);
19        locker = new LPLocker(splitter, launchToken);
20        ratchet.initLaunch(launchToken); // creator starts at 50%
21
22        // after graduation, 40 ETH of fees is earned below the threshold
23        locker.accrueFees{value: 40 ether}();
24
25        // FDV passes the threshold, but nobody calls poke()
26        ratchet.setFdv(launchToken, 12_000_000e18);
27
28        // another 60 ETH of fees is earned above the threshold
29        locker.accrueFees{value: 60 ether}();
30    }
31
32    function test_SplitDependsOnCallOrder() public {
33        uint256 snapshot = vm.snapshotState();
34
35        // order A: the creator's bot calls collect() before anyone pokes
36        locker.collect(1);
37        ratchet.poke(launchToken);
38        uint256 creatorIfCollectFirst = splitter.owed(creator);
39
40        vm.revertToState(snapshot);
41
42        // order B: someone calls poke() first, then collect()
43        ratchet.poke(launchToken);
44        locker.collect(1);
45        uint256 creatorIfPokeFirst = splitter.owed(creator);
46
47        // what the creator should get, based on when the fees were earned
48        uint256 creatorByAccrual = 40 ether * 5_000 / 10_000 + 60 ether * 2_000 / 10_000;
49
50        console2.log("fee backlog                       : 100 ETH");
51        console2.log("creator gets if collect() is first:", creatorIfCollectFirst / 1e18, "ETH");
52        console2.log("creator gets if poke() is first   :", creatorIfPokeFirst / 1e18, "ETH");
53        console2.log("creator share by when fees earned :", creatorByAccrual / 1e18, "ETH");
54
55        assertEq(creatorIfCollectFirst, 50 ether);
56        assertEq(creatorIfPokeFirst, 20 ether);
57        assertGt(creatorIfCollectFirst, creatorByAccrual, "collect-first overpays the creator");
58    }
59}
60

Run it:

1forge test --match-contract FeeRaceTest -vv
2

Output:

1Ran 1 test for test/FeeRace.t.sol:FeeRaceTest
2[PASS] test_SplitDependsOnCallOrder() (gas: 287463)
3Logs:
4  fee backlog                       : 100 ETH
5  creator gets if collect() is first: 50 ETH
6  creator gets if poke() is first   : 20 ETH
7  creator share by when fees earned : 32 ETH
8
9Suite result: ok. 1 passed; 0 failed; 0 skipped
10

The same 100 ETH backlog pays the creator 50 ETH if collect() runs first, and 20 ETH if poke() runs first.

Based on when the fees were actually earned, the creator should get 32 ETH. By sending collect() first, the creator takes 18 ETH more than that, and the protocol receives 18 ETH less.

The fix

The simplest fix is to stop the two calls from running in a free order. Have collect() call poke() first, so the share is always up to date before any fees are split.

1// LPLocker.sol
2function collect(uint256 tokenId) external {
3    // bring the creator's share up to date before splitting anything
4    ratchet.poke(launchToken);
5
6    uint256 fees = _collectFees(tokenId);
7    splitter.depositETH{value: fees}(launchToken, Stream.LP_FEES);
8}
9

Now the creator can't collect at a stale share. Running the same scenario with this change, the creator receives 20 ETH whichever way they try it.

This also changes the creator's incentive. Fees earned below a threshold are only paid at the higher share if they're collected before the threshold is crossed. So the creator's best move becomes collecting often, which keeps the backlog small and the split close to when fees were earned.

If the protocol wants the split to match accrual exactly, it can record how much fee has accrued each time the share changes, and pay each portion at the share that was active while it was earned. That's more work to build, but it removes the timing question completely.

Conclusion

Two calls that anyone can make, with nothing deciding their order, are a race. When a large payout depends on which one lands first, the person with the most to gain will make sure their call lands first. Here, that was the token creator, and the prize was the biggest fee pile the launch would ever earn.

Contents

Tell Us About Your Project
Subscribe to Newsletter
hashing bits image
Loading...
Loading...

WE SECURE EVERYTHING YOU BUILD.

From day-zero risk mapping to exchange-ready audits, QuillAudits helps projects grow with confidence. Smart contracts, dApps, infrastructure, compliance: secured end to end.

QuillAudits Logo


ISO 27001Circle Alliance Program
Uniswap FoundationAethiropt-collectivePolygon SPNBNB Chain Kickstart

All Rights Reserved. © 2026. QuillAudits - LLC