Share on XShare on LinkedInShare on Telegram
Auditing

Quill Findings: Token Creators Can Drain Early Buyers

Learn how a creator seed buy with no lockup let token creators sell into a bonding curve before graduation and drain early buyers, and how to fix it.

Author
QuillAudits Team
•October 13, 2026
Quill Findings: Token Creators Can Drain Early Buyers
Share on XShare on LinkedInShare on Telegram

A token launchpad in which the creator of any token can buy at the lowest price, let early buyers push the price up, and then sell everything back into the curve before the launch graduates.

The ETH that pays the creator out is the ETH those early buyers just put in.

This is the fifth blog in our series Quill Findings, where we share interesting vulnerabilities for auditor and developer education. If you missed the last one, you can read it here.

How the launchpad works

Every new token starts on a bonding curve. The curve holds the token supply and sells it to buyers in exchange for ETH.

Each buy pushes the price a little higher. Each sell pushes it back down and pays the seller out of the ETH the curve is holding.

Once the curve has raised enough ETH, the token graduates and moves to a regular pool.

Anyone can launch a token by calling createLaunch on the factory. The person who does that is the token creator. As part of the same transaction, the creator can make a seed buy, so they become the first holder of their own token.

Technical breakdown

The issue comes from three properties of the curve that combine on the normal trading path.

The first is that the seed buy is exclusive.

1function seedBuy(address beneficiary) external payable {
2    // only the factory can call this
3    if (msg.sender != factory) revert NotFactory();
4
5    // only on a fresh curve, before anyone else has bought
6    if (tokensSold != 0) revert WrongPhase();
7
8    _buy(beneficiary, msg.value, maxCreatorBuyWei);
9}
10

seedBuy runs inside createLaunch, in the same transaction that creates the curve. At that moment, only the factory can trade.

So the creator always gets the first tokens, at the lowest price the curve will ever have. Nobody can buy before them or alongside them.

Giving creators a first buy is a common launchpad feature. The issue starts with where those tokens go next.

1function _buy(address beneficiary, uint256 ethIn, uint256 cap) internal returns (uint256 out) {
2    if (state != State.ACTIVE) revert WrongPhase();
3    if (ethIn > cap) revert ExceedsCap(); // buying is capped
4
5    // ... price math ...
6
7    // tokens go straight to the beneficiary and can be sold next block
8    token.transfer(beneficiary, out);
9}
10

The seed tokens land in the creator's wallet right away, and they can move or sell them from the next block onward. Nothing holds them until the launch is done.

This is the property everything else depends on.

1/// @notice Any amount can be sold back into the curve in one call.
2function sell(uint256 tokensIn) external returns (uint256 ethOut) {
3    if (state != State.ACTIVE) revert WrongPhase();
4
5    // ... price math ...
6
7    token.transferFrom(msg.sender, address(this), tokensIn);
8    payable(msg.sender).transfer(ethOut);
9}
10

Buying is limited by maxBuyWei and maxCreatorBuyWei, but both checks only happen inside _buy. Selling has its own path, and it lets anyone sell their full balance in a single call.

Put together, the flow looks like this:

1// block N    createLaunch() + seedBuy()
2//            creator gets the first tokens at the lowest price
3//
4// block N+1  buyers start buying,
5//   ...      each buy pushes the price further up the curve
6//
7// block M    creator sells the whole seed back into the curve
8//            and is paid out of the ETH buyers just deposited
9

Impact

The creator's entry is guaranteed by the contract, and their exit is paid for by everyone who bought after them.

The curve pays sellers from the ETH it holds. When the creator sells at a higher price, that ETH came from the early buyers. Whatever the creator takes out as profit is ETH the buyers can no longer get back.

After the dump, the buyers hold tokens at a much higher entry price, with far less ETH left in the curve behind them. The launch is also pushed further away from graduation, since the curve just lost a large part of what it raised.

This can be done on every launch, by every creator, as part of normal trading.

Proof of concept

This is a minimal example to show the bug from the code side.

src/LaunchToken.sol:

1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.24;
3
4import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
5
6contract LaunchToken is ERC20 {
7    constructor(address curve, uint256 supply) ERC20("Launch Token", "LAUNCH") {
8        _mint(curve, supply); // the whole supply starts inside the curve
9    }
10}
11

src/BondingCurve.sol:

1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.24;
3
4import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
5
6/// @notice Minimal reproduction of the launchpad's bonding curve. Not the
7/// audited source. Price follows a constant-product curve on virtual reserves.
8contract BondingCurve {
9    enum State { ACTIVE, GRADUATED }
10
11    error NotFactory();
12    error WrongPhase();
13    error ExceedsCap();
14
15    address public immutable factory;
16    IERC20 public token;
17    State public state;
18
19    uint256 public virtualEth = 30 ether;
20    uint256 public virtualToken = 1_000_000_000e18;
21    uint256 public immutable k = 30 ether * 1_000_000_000e18;
22
23    uint256 public tokensSold;
24    uint256 public realEth;
25
26    uint256 public constant maxBuyWei = 5 ether;
27    uint256 public constant maxCreatorBuyWei = 2 ether;
28    uint256 public constant GRADUATION_ETH = 20 ether;
29
30    constructor() {
31        factory = msg.sender;
32    }
33
34    function setToken(IERC20 _token) external {
35        if (msg.sender != factory || address(token) != address(0)) revert NotFactory();
36        token = _token;
37    }
38
39    /// @notice Creator's first buy, run inside createLaunch on a fresh curve.
40    function seedBuy(address beneficiary) external payable {
41        if (msg.sender != factory) revert NotFactory();
42        if (tokensSold != 0) revert WrongPhase();
43        _buy(beneficiary, msg.value, maxCreatorBuyWei);
44    }
45
46    function buy() external payable {
47        _buy(msg.sender, msg.value, maxBuyWei);
48    }
49
50    function _buy(address beneficiary, uint256 ethIn, uint256 cap) internal returns (uint256 out) {
51        if (state != State.ACTIVE) revert WrongPhase();
52        if (ethIn > cap) revert ExceedsCap();
53
54        uint256 newVirtualEth = virtualEth + ethIn;
55        uint256 newVirtualToken = _divUp(k, newVirtualEth);
56        out = virtualToken - newVirtualToken;
57
58        virtualEth = newVirtualEth;
59        virtualToken = newVirtualToken;
60        tokensSold += out;
61        realEth += ethIn;
62
63        if (realEth >= GRADUATION_ETH) state = State.GRADUATED;
64
65        // tokens go straight to the beneficiary and can be sold next block
66        token.transfer(beneficiary, out);
67    }
68
69    /// @notice Any amount can be sold back into the curve in one call.
70    function sell(uint256 tokensIn) external returns (uint256 ethOut) {
71        if (state != State.ACTIVE) revert WrongPhase();
72
73        uint256 newVirtualToken = virtualToken + tokensIn;
74        uint256 newVirtualEth = _divUp(k, newVirtualToken);
75        ethOut = virtualEth - newVirtualEth;
76
77        virtualEth = newVirtualEth;
78        virtualToken = newVirtualToken;
79        tokensSold -= tokensIn;
80        realEth -= ethOut;
81
82        token.transferFrom(msg.sender, address(this), tokensIn);
83        payable(msg.sender).transfer(ethOut);
84    }
85
86    function _divUp(uint256 a, uint256 b) internal pure returns (uint256) {
87        return (a + b - 1) / b;
88    }
89}
90

src/LaunchFactory.sol:

1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.24;
3
4import {BondingCurve} from "./BondingCurve.sol";
5import {LaunchToken} from "./LaunchToken.sol";
6
7contract LaunchFactory {
8    /// @notice Anyone can launch a token. The seed buy runs in the same transaction.
9    function createLaunch() external payable returns (BondingCurve curve) {
10        curve = new BondingCurve();
11        LaunchToken token = new LaunchToken(address(curve), 800_000_000e18);
12        curve.setToken(token);
13        curve.seedBuy{value: msg.value}(msg.sender);
14    }
15}
16

test/CreatorDump.t.sol:

1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.24;
3
4import {Test, console2} from "forge-std/Test.sol";
5import {LaunchFactory} from "../src/LaunchFactory.sol";
6import {LaunchToken} from "../src/LaunchToken.sol";
7import {BondingCurve} from "../src/BondingCurve.sol";
8
9contract CreatorDumpTest is Test {
10    address creator = makeAddr("creator");
11
12    // formats wei as ETH with 2 decimals, for readable logs
13    function _eth(uint256 wei_) internal pure returns (string memory) {
14        uint256 cents = (wei_ + 5e15) / 1e16;
15        string memory dec = cents % 100 < 10 ? string.concat("0", vm.toString(cents % 100)) : vm.toString(cents % 100);
16        return string.concat(vm.toString(cents / 100), ".", dec, " ETH");
17    }
18
19    function test_CreatorDrainsEarlyBuyers() public {
20        LaunchFactory factory = new LaunchFactory();
21
22        // creator launches the token with a 2 ETH seed buy
23        vm.deal(creator, 2 ether);
24        vm.prank(creator);
25        BondingCurve curve = factory.createLaunch{value: 2 ether}();
26        LaunchToken token = LaunchToken(address(curve.token()));
27
28        // five early buyers put in 3 ETH each, pushing the price up
29        for (uint256 i; i < 5; i++) {
30            address buyer = makeAddr(vm.toString(i));
31            vm.deal(buyer, 3 ether);
32            vm.prank(buyer);
33            curve.buy{value: 3 ether}();
34        }
35
36        // creator sells the whole seed back into the curve
37        uint256 seed = token.balanceOf(creator);
38        vm.startPrank(creator);
39        token.approve(address(curve), seed);
40        curve.sell(seed);
41        vm.stopPrank();
42
43        console2.log("creator paid          :", _eth(2 ether));
44        console2.log("creator got back      :", _eth(creator.balance));
45        console2.log("buyers put in         :", _eth(15 ether));
46        console2.log("ETH left in the curve :", _eth(curve.realEth()));
47
48        assertGt(creator.balance, 2 ether, "creator made a profit");
49        assertLt(curve.realEth(), 15 ether, "buyers' ETH is no longer fully there");
50    }
51}
52

Run it:

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

Output:

1Ran 1 test for test/CreatorDump.t.sol:CreatorDumpTest
2[PASS] test_CreatorDrainsEarlyBuyers() (gas: 2471645)
3Logs:
4  creator paid          : 2.00 ETH
5  creator got back      : 4.19 ETH
6  buyers put in         : 15.00 ETH
7  ETH left in the curve : 12.81 ETH
8
9Suite result: ok. 1 passed; 0 failed; 0 skipped
10

The creator launches the token with a 2 ETH seed buy. Five buyers then put in 3 ETH each, pushing the price up.

The creator sells the whole seed and walks away with 4.19 ETH. The buyers put in 15 ETH, but only 12.81 ETH is left in the curve to back their tokens. The 2.19 ETH difference is exactly the creator's profit.

Why a sell cap isn't enough

It's tempting to add a limit to sell. But the creator can split the dump across several blocks, the same way buyers already split their buys to stay under maxBuyWei.

A cap only spreads the exit over more transactions. The real issue is that the seed can be sold before graduation at all.

The fix

Lock the creator's seed until the token graduates. The simplest way is to keep the seed in escrow on the curve, and let the creator claim it only after graduation.

1function seedBuy(address beneficiary) external payable {
2    if (msg.sender != factory) revert NotFactory();
3    if (tokensSold != 0) revert WrongPhase();
4
5    // the curve keeps the seed instead of sending it out
6    uint256 out = _buy(address(this), msg.value, maxCreatorBuyWei);
7    seedEscrow[beneficiary] += out;
8}
9
10function claimSeed() external {
11    // the seed only unlocks once the launch has graduated
12    if (state != State.GRADUATED) revert WrongPhase();
13
14    uint256 amount = seedEscrow[msg.sender];
15    seedEscrow[msg.sender] = 0;
16    token.transfer(msg.sender, amount);
17}
18

Keeping the tokens in escrow matters. If the lock only blocked sell() for the creator's wallet, they could move the seed to a fresh address and sell it from there.

This also lines the creator up with the reason the launchpad exists. They only get their tokens once the launch actually succeeds.

If a full lock is too strict for the product, vesting that only starts at graduation is the minimum that removes the early dump.

Conclusion

A creator's first buy is a normal launchpad feature. It turns into a problem when those tokens can leave the curve before the launch is done, because the exit gets paid by the people who trusted the launch first.

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