4 chapters · 14 questions
Bonding curves: how launchpads price a token, and where the bugs are
A bonding curve is a contract that sells a token and buys it back at a price set by a formula. The formula only looks at how many tokens have been sold so far, so every buy makes the next token a bit more expensive and every sell makes it cheaper.
Most memecoin launchpads use the curve that pump.fun made popular: Uniswap's x · y = k formula with virtual starting reserves. This lesson follows one example launch from start to end. Chapter 1 explains why launchpads use a curve instead of a normal pool. Chapter 2 covers the formula and the real money behind it, chapter 3 buying and selling, and chapter 4 how the token moves to a real pool at the end. Every chapter uses the same numbers, and chapters 2 to 4 end with a list of things to check in the code. Some quiz questions show a short Solidity function with one bug in it.
One example, not the only design
This lesson follows one simple launch so the numbers stay easy to check. Real launchpads differ in almost every detail. Some use other curve shapes, such as linear, fixed-price or curves built from several price ranges. They graduate into different pools: Uniswap V2, V3 or V4, PancakeSwap, Raydium, PumpSwap or Meteora, and some lock the LP tokens instead of burning them. Fees vary too: some pay the creator a share of every trade, most charge a protocol fee, and some take a fee at graduation.
How to read the formulas
- x
- Purple marks money. x is the amount of backing currency, such as SOL or ETH, in the formula. It starts above zero; chapter 2 explains why.
- y
- Blue marks tokens. y is the number of tokens in the formula. It goes down as people buy.
- Δy
- Orange marks the number being worked out, for example the tokens a buyer gets.
- Δ
- Means "change in". Δx is the money a buyer pays in.
Chapter 1 of 4
A new token has nobody to trade with, so the contract trades with you itself
To buy a token, someone has to sell it to you. That someone is the counterparty. Nobody has bought a token created a minute ago, and it has no pool, so there is nobody to trade with and no price.
Before launchpads: the creator seeds a pool
Before launchpads, the creator put the new token and some of their own ETH into an AMM pool, and everyone traded against that pool's liquidity. This had three problems. The creator needed ETH up front. The creator picked the opening price by choosing how much of each to deposit. And the creator got the pool's LP tokens, so they could withdraw all the ETH at any time and leave buyers holding a token nobody could sell. That is one way to rug pull.
With a curve, the contract is the counterparty
A bonding curve contract sells tokens to anyone who pays and buys them back from anyone who sells. The price comes from the formula, so you can work it out before you trade. Buys push it up and sells push it down.
Nobody has to deposit anything to start. The opening price comes from constants set at launch, and after that only trading moves it. This is price discovery. There are no LP tokens, so nobody has a claim on the money buyers paid in. The only way to get money out is to sell tokens back to the curve.
- LaunchSomeone creates the token through the launchpad. The formula's starting values set the first price. Nobody deposits money.
- Trading on the curveAnyone can buy from or sell to the contract at the formula's price.
- GraduationWhen enough money has come in, the curve stops trading and moves the money and a set amount of tokens into an AMM pool.
- AMM poolThe token now trades like any other. The launchpad burns the pool's LP tokens, so nobody can withdraw the liquidity.
What the curve guarantees
- Until graduation, you can always buy from or sell to the contract.
- You can calculate the price before you trade.
- Money paid in stays in the contract until someone sells.
What it does not guarantee
- That you get in first. Bots often buy in the launch block.
- That the price is fair. It only shows what the last buyers paid.
- That the contract has no bugs.
Why the curve ends
The curve only sells part of the supply, and you can only trade on it through the launchpad. Wallets, DEX aggregators and other protocols work with normal pools. So when enough money has come in, the contract puts that money and a matching amount of tokens into an AMM pool and stops trading. This is graduation, covered in chapter 4.
Why doesn't the creator have to put in any money when launching on a bonding curve?
The contract itself sells to the first buyer, at a price the formula gets from its starting values. There is no pool until graduation, and nobody lends anything.
- Why not a
- There is no pool before graduation, and buyers deposit nothing. They pay quote and get tokens from the contract.
- Why not c
- Nobody lends anything. The opening price comes from virtual reserves, which only exist in the formula.
A creator who seeded a pool could pull the liquidity out. Why can't they do the same with a curve?
A curve has no LP tokens, so no address has a claim on the money in it. Money only leaves when someone sells tokens back at the formula's price. The creator can still sell their own tokens, and a buggy or malicious admin function can still drain the contract. That is what you check in an audit.
- Why not b
- Locking the creator's tokens stops them selling, but it has nothing to do with the money in the contract. Many curves don't lock them at all.
- Why not c
- The money sits in the curve contract itself. No exchange holds it.
Chapter 2 of 4
The x · y = k formula, with virtual starting reserves
A Uniswap V2 pool holds two tokens and only allows trades that keep the two amounts multiplied together the same (ignoring fees, which make it grow a little). That is the constant product rule, x · y = k, and k is called the invariant. When you pay quote in, x goes up, so y has to go down to keep the product at k. The tokens taken off y are what you get.
A launch curve uses the same rule, but it starts with no money, and with x = 0 both the product and the price are 0. So the contract does its math as if it already held 30 quote and 1,000,000,000 tokens. These are virtual reserves. They only exist in the formula, they set the opening price, and they can never be paid out.
The price of the next token, the spot price, is x divided by y. In this example the curve sells 800M tokens and stops when y reaches 200M. Those last 200M are never sold on the curve; chapter 4 shows where they go.
In wordsx times y is the same before and after every trade.
In wordsTo find the tokens you get, add your quote to x, divide k by the new x to get the new y, and subtract that from the old y. The spot price is x over y.
Symbols in play
- x
- Quote in the formula. Starts at 30, all of it virtual. Real money raised is x − 30.
- y
- Tokens in the formula, in millions. Starts at 1,000.
- k
- Invariant. 30 × 1,000 = 30,000 (quote × million tokens).
- Δx
- Quote you pay in.
- Δy
- Tokens you receive, in millions.
- p
- Spot price, in quote per million tokens.
Worked example: buy with 10 quote at launch
- Opening spot price.p = 30 / 1,000 = 0.030 per million tokens
- Quote side after the buy.x = 30 + 10 = 40
- Token side that keeps the product at 30,000.y = 30,000 / 40 = 750
- Tokens out.Δy = 1,000 − 750 = 250 million tokens
- You paid 0.040 per million on average, more than the opening 0.030. The next buyer starts at 0.0533.10 / 250 = 0.040 · 40 / 750 = 0.0533
- The curve ends when 800M are sold and y = 200.x = 30,000 / 200 = 150 → real money raised = 150 − 30 = 120
Auditor's note · how big k gets
On-chain, amounts are in wei. One billion 18-decimal tokens is 10²⁷ wei, and 30 of an 18-decimal quote token is 3 × 10¹⁹ wei, so k is about 3 × 10⁴⁶. Each reserve fits in a uint128, which contracts often use to pack two reserves into one storage slot, but k does not. Check that k is a uint256 and that x * y is computed in 256 bits, not as two uint128 values multiplied together.
Real money and virtual money
x is the 30 virtual quote plus the real money buyers have paid in. Only the real part is in the contract, so the contract has to track it separately and pay every sell out of it.
On a constant product curve the numbers work out exactly. If every holder sells back, x returns to 30 and the payouts add up to the real money taken in, so the contract can always pay every seller. This is called solvency. It holds as long as fees are kept out of the reserve and every rounding goes the contract's way.
In wordsThe real money held is x minus the virtual starting quote x₀, which is 30 here.
Worked example: everyone sells back after the 10 quote buy
- State after the buy.x = 40, y = 750 → R_real = 40 − 30 = 10
- Every holder sells all 250M back.y = 1,000 → x = 30 → payouts 40 − 30 = 10 = R_real
- The payouts equal the real reserve exactly. With a 1% fee, sellers get 9.90 and the 0.10 fee sits in a separate fee balance.10 − 10 = 0 left, no shortfall
Auditor's note · never pay out the virtual amount
If a sell pays out based on x without subtracting the virtual 30, it pays money that was never there, and the last sellers find the contract empty. Check that no payout can be more than x − 30, especially right after launch, and that fees are kept in their own variable, not in the balance the curve prices from.
Why do pump.fun-style curves start from virtual reserves instead of empty ones?
With x = 0, k is 0 and the price x/y is 0, so the formula can't price anything. The virtual amounts give a real opening price and set how steep the curve is. They can't be withdrawn; only the real money on top of them can be paid out.
- Why not b
- Virtual tokens only exist in the formula. They are never sold, and fees are paid in quote.
- Why not c
- The virtual quote was never deposited, so there is nothing to withdraw. Only real money from buyers can be paid out.
At launch (x = 30, y = 1,000M), how many million tokens does a buy with 20 quote receive?
- New quote side: 30 + 20 = 50.
- New token side: 30,000 / 50 = 600.
- Tokens out: 1,000 − 600 = 400 million. Twice the money of the worked example bought 1.6 times the tokens.
After 250M tokens are sold, every holder sells them all back. With no fees, how much does the curve pay out in total?
Selling everything takes x from 40 back to 30, so the payouts add up to 40 − 30 = 10, exactly what buyers paid in. The 30 virtual quote was never in the contract.
- Why not b
- 40 includes the 30 virtual quote, which was never in the contract. Selling everything only takes x back to 30.
- Why not c
- 30 is the virtual amount. It is where x stops when everything is sold, not money that gets paid out.
Almost every buy on this curve reverts. Which line is the root cause?
function buy(uint256 quoteIn, uint256 minOut) external {
uint256 newX = vQuote + quoteIn;
uint256 newY = k / newX;
uint256 out = vToken - newY;
require(out >= minOut && out > 0, "out");
require(newX * newY >= k, "k");
vQuote = newX;
vToken = newY;
quote.safeTransferFrom(msg.sender, address(this), quoteIn);
token.safeTransfer(msg.sender, out);
}
- Impact
- Integer division rounds
newYdown unlessnewXdivideskexactly, which with amounts in wei almost never happens. SonewX * newYis almost always a little belowk, thekcheck reverts, and almost every buy fails. Nobody can buy and the curve never graduates. No money is lost, but the contract's main function stops working: Medium severity. - Fix
- Round
newYup:newY = (k + newX - 1) / newX;. ThennewX * newYis never belowkand the check passes. Removing the check would also stop the reverts, but then every buy gives out up to 1 wei too many tokens. - Why not a
- Checking minOut before the transfers is the normal order (checks-effects-interactions).
- Why not c
- Updating
vQuoteandvTokenbefore the transfers is also the normal order. IfsafeTransferFromfails, the whole call reverts and the state change is undone.
Security check · curve math
-
Tokens out round down, required quote in rounds up, sell payouts round down, fees round up.
Details
- What
- Every division that rounds toward the trader gives away up to 1 wei and lets
x · yfall belowk. One wei is rarely worth the gas, but with low-decimal tokens, or when the rounded value is multiplied by a price or share afterwards, it can add up to real money. If the code also checksx * y >= kafter rounding the wrong way, the check fails and the trade reverts instead. - Check
- Go through every division and ask who gains if it rounds down. Do this separately for buys with exact input, buys with exact output, sells, fees and graduation.
- Fix
- Use explicit
mulDivUp/mulDivDownand ceiling division where the protocol needs the larger number.
-
If the last few wei of quote buy zero tokens, or are below a minimum buy, every buy reverts and the curve never graduates.
Details
- What
- An attacker buys until the curve needs only a few wei of quote to finish. Any later buy gets cut down to that amount, and two things can make it revert. If one wei of the token is worth more than one wei of quote (a token with few decimals, or a very expensive one), the output rounds to 0 and a zero-output check fails. If the contract has a minimum buy size, the cut-down amount is below it. With this lesson's numbers neither happens: near the end, 1 wei of quote still buys about 1.3 million wei of tokens.
- Check
- How does the code cut down the last buy? Does it compute the output with the formula, which can round to 0, or hand over all remaining curve tokens? Does a minimum buy size apply to the cut-down amount? Can sells unfreeze the curve, and are sells allowed at that point?
- Fix
- On the last buy, give the buyer all remaining curve tokens instead of using the formula, or graduate once the quote still needed is below a small dust amount.
-
After many trades the real balances are a few wei off from what the launch math predicted, and an
==check reverts.Details
- What
- Amounts worked out in advance, such as the tokens left for the pool or the quote raised at the end, assume exact math. Every rounded trade moves the real amounts by a wei.
- Check
- Search for
==on balances and amounts in graduation and migration code. - Fix
- Derive pool amounts from actual balances at migration time, or compare with a tolerance and sweep the dust.
-
Curve sales plus pool tokens plus any creator share must never exceed max supply.
Details
- What
- If a buy can go past the 800M meant for the curve, the extra comes out of the tokens reserved for the pool, or gets minted on top of the supply.
- Check
- Every buy is cut off at the cap. The pool's tokens are set aside once, as a constant. If there is a mint function, it checks the cap itself.
- Fix
- Cut buys off at the remaining curve tokens and check
totalSupply() <= MAX_SUPPLYwherever tokens are minted.
-
All possible sell payouts added up must never be more than the real reserve.
Details
- What
- Fees, rounding errors or virtual amounts mixed into the reserve can leave the last sellers unpaid.
- Check
- Tracked reserve equals x − x₀ after every trade. Fees are stored separately. Sell payouts round down.
- Fix
- Write a fuzz test: random buys, then sell everything back in random chunks. Every sell must succeed and the reserve must never go below zero.
Chapter 3 of 4
Selling, fees and sandwich attacks
Selling is a buy run backwards. You give tokens back, y goes up, so x has to go down to keep x · y at k, and you are paid the difference. Without fees, if you buy with 10 quote and sell the tokens straight back, you get exactly 10 back.
Launchpads charge a fee f on each trade, usually around 1%. In this lesson a buyer pays the fee on top, and a seller has it taken off the payout. Many contracts instead take the buyer's fee out of the amount sent, like the buy function in the quiz below; the cost is about the same. So buying and selling straight back costs you about 2%. That gap between the price you buy at and the price you can sell at is the spread.
Every trade moves the price, so the order of trades matters. A bot that sees your buy waiting in the mempool can buy just before you, let your buy push the price up, and sell right after. This is a sandwich attack, a kind of front-running. You protect yourself with minOut, the fewest tokens you will accept. If the price has moved too far by the time your trade runs, it reverts. How far you let it move is your slippage tolerance.
In wordsAdd the tokens sold to y, divide k by that to get the new x, and pay out the difference.
In wordsThe attacker pays a plus the fee to buy first, then gets the sell payout minus the fee. The attack is worth it when your buy moves the price by more than the two fees.
Symbols in play
- Δy
- Tokens sold back.
- out
- Quote paid out for those tokens, before the fee.
- f
- Fee as a fraction. 1% is 0.01.
- a
- Quote the attacker spends on the front-run.
Worked example: 1% fee, launch state x = 30, y = 1,000M
- You buy with 10 quote and get 250M tokens, as in chapter 2.you pay 10 × 1.01 = 10.10
- You sell the 250M straight back. The curve returns to x = 30.out = 40 − 30,000 / 1,000 = 10 → you receive 10 × 0.99 = 9.90
- You lost the fee twice, once on each trade.10.10 − 9.90 = 0.20
- Start over. This time a bot buys first with 10 quote and gets those 250M.x = 40, y = 750 · attacker pays 10.10
- Your 10 quote now buys at a higher price.y = 30,000 / 50 = 600 → you get 750 − 600 = 150M instead of 250M
- The bot sells its 250M back.y = 850 → x = 30,000 / 850 = 35.29 → out = 50 − 35.29 = 14.71
- The bot's profit after both fees. With minOut set close to 250M, your buy would have reverted and the bot would have lost its fees instead.14.71 × 0.99 − 10.10 = 4.46
With no other traders, you buy tokens and sell the same amount right back. Why do you end with less quote than you started with?
Selling the same tokens back puts the curve exactly where it started, so the curve itself returns all of your quote. What you lose is the fee on each trade: in the example you paid 10.10 and got 9.90 back. k does not change.
- Why not a
- The price did go up during your buy, but your sell moved it back down by the same amount, so the curve returns all of your quote.
- Why not b
- A round trip puts x and y back where they started, so k is unchanged.
From launch, an attacker front-runs with 20 quote. How many million tokens does your 10 quote buy now?
- After the attacker: x = 50, y = 30,000 / 50 = 600.
- After your buy: x = 60, y = 30,000 / 60 = 500.
- You get 600 − 500 = 100 million, down from 250 million.
Which of these is a bug in this buy function?
function buy(uint256 minOut, uint256 deadline) external payable {
require(block.timestamp <= deadline, "expired");
uint256 fee = (msg.value * feeBps + 9_999) / 10_000; // round up
uint256 out = quoteBuy(msg.value - fee);
require(out > 0, "zero out");
vQuote += msg.value - fee;
vToken -= out;
token.safeTransfer(msg.sender, out);
}
- Attack
minOutis never checked, so a bot can sandwich this buy and the buyer gets far fewer tokens than the app showed. The parameter looks like protection but does nothing.- Fix
- Add
require(out >= minOut, "slippage");before the transfer. - Why not b
- Taking the fee out first and pricing only the rest is correct. The fee never enters the curve, so it is not counted in the reserve, and it rounds up in the protocol's favor.
- Why not c
<=lets the trade run up to and including the deadline second, which is the usual convention (Uniswap's routers do the same). With<the trade would only expire one second earlier. Neither choice is a bug.
Security check · trading
-
Without both, any pending trade can be sandwiched or executed hours later at a worse price.
Details
- What
- minOut limits how far the price can move against the trader. The deadline limits how long a signed transaction can sit in the mempool before it runs.
- Check
- Every entry point (buy, sell, buy with exact output, router paths) takes both and actually compares them. The frontend doesn't fill in
0andtype(uint256).maxby default. - Fix
require(out >= minOut)andrequire(block.timestamp <= deadline)on every path.
-
A sandwich pays when the victim's price move exceeds roughly twice the fee.
Details
- What
- The attacker earns the price move the victim causes, minus a fee on each leg. Early on the curve, small trades move the price a lot.
- Check
- Compute the price impact of typical buys near launch. Confirm minOut is mandatory and cannot be bypassed through a second entry point.
- Fix
- Make slippage limits mandatory. A higher fee in the first blocks after launch also helps.
-
The first tokens on a curve are the cheapest, and bots try to buy them in the launch block. This is sniping.
Details
- What
- If creating the token and the creator's first buy are separate transactions, a bot watching the mempool can buy in between.
- Check
- Can the creator buy in the same transaction that creates the token? Is there a per-wallet or per-transaction cap in the first blocks? Can the creator bundle a large buy that nobody sees until it lands?
- Fix
- Let the creator buy in the create transaction, up to a public cap. Limit buy size in the first blocks, or start with a high fee that drops over the first blocks.
Chapter 4 of 4
Graduation: moving from the curve to a real pool
Once the real reserve reaches a target, the contract stops trading on the curve. This is graduation, also called migration. The contract creates a pool on an AMM, deposits the quote it collected together with a matching amount of tokens, and burns the LP tokens so nobody can withdraw the liquidity.
The pool has to open at the price where the curve stopped. If it opens lower, every holder loses value the moment the pool exists, and the first pool buyers get tokens cheaper than the last curve buyers paid. If it opens higher, the last curve buyers can sell into the pool right away at a profit, taken out of the liquidity the curve just deposited.
The last buy on the curve usually asks for more tokens than are left. The contract fills what it can and sends the rest of the money back as a refund. Bugs here usually come from a minOut check that is not scaled to the partial fill, or from a refund sent before the contract's state is updated.
In wordsDeposit as many tokens as it takes for the pool's price (quote divided by tokens) to equal the curve's last price.
In wordsIf only part of a buy is filled, scale minOut down by the same fraction before checking it.
Symbols in play
- Rreal
- Real quote raised. 120 at graduation in our example.
- pend
- Spot price at the end: 150 / 200M = 0.75 per million tokens.
- sent, used
- Quote the buyer sent, and the part the curve could take.
- minOut
- Minimum tokens the buyer set for the full amount sent.
- minOut′
- That minimum scaled to the filled part.
Worked example: graduation and the last buy
- Market cap at the end, with 1,000M total tokens, before the 40M left over are burned.0.75 × 1,000 = 750 quote
- Tokens needed to pair with the 120 quote raised, at that price.120 / 0.75 = 160 million tokens
- Where all 1,000M tokens end up: 800M sold, 160M in the pool, and the 40M left over are burned.800 + 160 + 40 = 1,000 million
- Now the last buy. 90 quote has been raised so far (x = 120, y = 250). Alice sends 50. Her wallet worked out her tokens as if the curve kept going and set minOut 1% below that.(250 − 30,000/170) × 0.99 = 73.53 × 0.99 → minOut = 72.79M
- Only 30 more quote fits. She gets every token down to y = 200, and 20 quote back.out = 250 − 200 = 50M · refund = 20
- Checking 50M against her minOut fails, as it always will when minOut is more than the tokens left. The scaled check passes.50 ≥ 72.79 ✗ · minOut′ = 72.79 × 30/50 = 43.68 → 50 ≥ 43.68 ✓
Auditor's note · units at the handover
The curve may hold native ETH or SOL, but the pool needs the wrapped token. Check that the contract wraps before it deposits, that the pool price accounts for both tokens' decimals (a 6-decimal quote against an 18-decimal token is off by 10¹² if it doesn't), and that the graduation check uses >= so a rounded total can't skip past the target.
Donations: money that did not come from a buy
Anyone can transfer tokens straight to the curve contract, which is called a donation. If the code reads balanceOf to get its reserve, it counts the donation as if buyers had paid it. The price goes up without a trade, and a graduation check that reads the balance triggers before buyers have paid the full target. The same works on the pool itself before graduation; the checklist below covers both.
Worked example: a donation of 10 after the 10 quote buy
- The curve is at x = 40 and holds 10 real quote. Someone transfers 10 more straight to it.tracked R_real = 10 · balance = 20
- Code that prices from its balance now uses x = 50 instead of 40.p = 50 / 750 = 0.0667 vs 40 / 750 = 0.0533 → +25%
- A graduation check that reads the balance wants 120, and 10 of what it counts was never paid by a buyer.graduates after 110 of real buys instead of 120
The curve needs 12 more quote to graduate. Alice sends 30 with a minOut sized for the full 30. What should the contract do?
Fill the 12 that fit and refund 18. Her minOut has to be scaled to the part that was filled; otherwise the contract compares a partial fill against a full-size minimum and reverts.
- Why not a
- If every buy larger than what is left reverts, a buyer has to send exactly the amount needed, which is hard to hit while others trade. The curve can sit just short of graduation.
- Why not c
- The curve only has 800M tokens to sell. Selling past that takes tokens meant for the pool or mints new ones, and the pool then opens at the wrong price.
Bob sends 50 quote with minOut = 60M tokens. Only 30 quote fits before graduation. What is his scaled minOut′, in millions?
- Share filled: used / sent = 30 / 50 = 0.6.
- minOut′ = 60 × 0.6 = 36 million.
- He receives the 50M left on the curve in the example, so the scaled check passes.
Anyone can change what this sell pays out. Which line lets them?
function sell(uint256 amount, uint256 minOut) external {
uint256 x = VIRTUAL_QUOTE + quote.balanceOf(address(this));
uint256 newY = vToken + amount;
uint256 out = x - Math.ceilDiv(x * vToken, newY);
require(out >= minOut, "slippage");
vToken = newY;
token.safeTransferFrom(msg.sender, address(this), amount);
quote.safeTransfer(msg.sender, out);
}
- Attack
- A donation raises
x, so the price and every sell payout go up without a trade. Anyone who wants a higher price, for example to move the price the pool opens at, can get it with a donation. Fees held in the same balance are counted too, so they get paid out to sellers and the fee recipient later finds less than it is owed. - Fix
- Track the real reserve in storage, change it only in buy and sell, and keep fees in a separate variable. Sweep any surplus balance.
- Why not b
- Taking the seller's tokens before sending the payout is the safe order. If the token transfer fails, nothing is paid.
- Why not c
- Rounding the new quote side up is correct: it rounds the payout down, in the curve's favor.
This runs at the end of the last buy. The order of two steps lets the buyer reenter. Which?
function _finishCurve(uint256 used, uint256 sent) internal {
uint256 refund = sent - used;
if (refund > 0) {
(bool ok, ) = msg.sender.call{value: refund}("");
require(ok, "refund failed");
}
graduated = true;
_migrate();
}
- Attack
- The refund call runs the buyer's code while
graduatedis still false. Fromreceive(), the buyer can callbuyorsellagain on a curve that is already full, or get_migrateto run twice. This is reentrancy. - Fix
- Follow checks-effects-interactions: set
graduated = trueand finish state changes first, add anonReentrantguard, and refund last (or credit it for a later withdrawal). - Why not b
.callis the right choice.transferforwards only 2,300 gas, so many smart contract wallets could not receive the refund.- Why not c
- Reverting when the refund fails only affects a buyer whose own wallet rejects ETH, and it does not let anyone reenter. Crediting the refund for a later withdrawal avoids even that.
This migration can be blocked by anyone, forever. Why?
function _migrate() internal {
address pair = factory.createPair(address(token), address(weth));
weth.deposit{value: realQuote}();
weth.transfer(pair, realQuote);
token.transfer(pair, TOKENS_FOR_POOL);
IUniswapV2Pair(pair).mint(DEAD);
}
- Attack
- The token address is known at launch. An attacker calls
factory.createPairfirst. Now_migratereverts on every attempt and the curve can never graduate. With a pre-created pair the attacker can also donate WETH into it, so the pool opens at the wrong price. - Fix
- Use
getPairand create only if missing. If it exists, handle balances already in the pair (skimorsync, then size deposits from actual reserves). Or create the pair at launch from inside the factory. If the launchpad graduates into Uniswap V4 instead, it can attach its own hook whosebeforeInitializereverts unless the caller is the launchpad. The hook address is part of the pool's ID, so nobody else can create that pool first. - Why not a
- Sending the LP tokens to a dead address locks the liquidity for good. That is intended.
- Why not b
- A V2 pair only reads its balances when
mintis called, after both transfers, so the order of the two transfers does not matter.
Security check · graduation
-
Without scaling, a large last buy usually reverts. If the code skips the check instead, the buyer has no protection.
Details
- What
- The buyer sets minOut for the full amount, but the curve only uses part of it. Comparing the full minOut to a partial fill reverts even when the price was fine. Code that skips the check on this path leaves the buyer open to a sandwich.
- Check
- Find the code that cuts down the last buy. Is minOut checked there, and against what?
- Fix
minOut * used / sent(rounded up), thenrequire(out >= scaledMin).
-
A V2
createPaircall fails if the pair already exists, and a concentrated-liquidity pool that someone else initialized starts at the price they picked.Details
- What
- Anyone can work out the pool's address or ID in advance. For V2-style pools, creating the pair first makes graduation's
createPaircall revert. For V3 or V4 pools, initializing the pool at an extreme price means the curve's money is deposited at that price, or the deposit fails. The attacker can also add a tiny bit of liquidity so the pool already has a price and reserves that graduation has to deal with. - Check
- Does graduation always call create, or does it first look for an existing pool? Does it read the price and reserves already there? What happens to the deposit if the starting price is far from the curve's final price?
- Fix
- Handle a pool that already exists: read its state and trade the price back if needed. For V4, use a
beforeInitializehook that only lets the launchpad initialize the pool. Or create the pool in the same transaction as the launch.
-
Any math that reads
balanceOforaddress(this).balancecounts money anyone can send.Details
- What
- A donation raises x: the price goes up, sells pay more, and a graduation check that reads the balance triggers early, so the pool opens at the wrong price.
- Check
- Is the reserve a stored variable? Does graduation read balances or stored values? Does a
receive()function accept plain ETH? - Fix
- Track reserves in storage; ignore or sweep surplus balances.
-
Tokens already sitting in the pair are counted when liquidity is minted.
Details
- What
- A V2 pair mints LP tokens based on its balance minus its stored reserves, so tokens sent in earlier count as part of the first deposit. Donated WETH raises the opening price, and the attacker sells curve tokens they bought cheap into the pool at that price.
- Check
- Does migration account for existing balances in the pair? Can curve tokens be transferred to the pair before graduation?
- Fix
- Call
syncorskimfirst and size deposits from reserves; block transfers of the launch token to the pair until graduation.
-
An external call before state is final lets the receiver trade again or run migration twice.
Details
- What
- Native ETH refunds, token hooks (ERC-777, ERC-1363) and launch callbacks all run code at the receiving address.
- Check
- Where is the first external call when the last buy runs? Is
graduatedset before it? Is there a reentrancy guard on buy, sell and migrate? - Fix
- Checks-effects-interactions plus
nonReentrant; send the refund last.
-
transferonly forwards 2,300 gas. A smart wallet whosereceivefunction needs more runs out of gas, and the last buy reverts.Details
- What
- Smart contract wallets, multisigs and proxies often need more than 2,300 gas in
receiveorfallback. If they do, every last buy they send fails. - Check
- Search for
.transfer(and.send(on native refunds. - Fix
(bool ok, ) = to.call{value: amount}(""); require(ok);behind a reentrancy guard, or credit the refund for a separate withdraw.
-
If it does, every retry fails the same way and the raised money is stuck.
Details
- What
- The launchpad's own checks pass, but
modifyLiquidityreverts on limits that only V4 core checks. The total liquidity of all positions that use a tick as a boundary can be at mostmaxLiquidityPerTick, anduint128amounts have to fit intoint128deltas. If graduation goes over either limit, it reverts every time. - Check
- Compare the liquidity graduation adds with
maxLiquidityPerTickfor the pool's tick spacing. Follow every cast fromuint128toint128for amounts and liquidity. - Fix
- Calculate the liquidity before minting and keep it under the per-tick limit, spreading it over several ranges if needed. Use checked casts, and give graduation a way to recover if the deposit still fails.
Before and after graduation
The curve and the pool use the same formula with different reserves
The curve and the pool both use x · y = k. The difference is where the reserves come from and who can trade against them.
| Launch curve | AMM pool after graduation | |
|---|---|---|
| Price rule | x · y = k, with virtual x₀, y₀ | x · y = k, real reserves only |
| Liquidity from | Buyers' quote and the contract's own tokens. Virtual reserves only set the price. Nobody deposits. | The curve's reserve plus the tokens set aside for the pool. |
| Can it be pulled | Only through sells, at curve prices. | Not if LP tokens are burned. |
| Opening price | x₀ / y₀ (0.03 per 1M here) | The curve's final price (0.75 per 1M) |
| Tokens traded | A fixed allotment (800M) | Whatever was deposited (160M) plus holders' tokens |
| Ends | At the graduation target | Never |
| Who can trade | Only through the launchpad contract | Any router or aggregator |
| Main audit risks | Rounding, sniping, a reserve read from balanceOf, the last buy | Pre-created pools, donations, opening price |
Quick reference · checked October 2026
Who uses constant product curves
Launchpads change often. Check the current docs before relying on a row.
| Protocol | Role | How it is used |
|---|---|---|
| Pump.fun (Solana) | Launch curve | Constant product with virtual reserves, as in chapter 2. |
| Raydium LaunchLab (Solana) | Launch curve | Supports constant product, linear and fixed-price curves. Constant product with virtual reserves is the default and the most used. |
| PumpSwap (Solana) | Graduation pool | Pump.fun's own AMM. Graduated pump.fun tokens migrate here. |
| Raydium AMM (Solana) | Graduation pool | Raydium's AMM. Graduated pump.fun tokens migrated here before PumpSwap launched. |
| Uniswap V2 (EVM) | Graduation pool | The classic EVM x · y = k pool, and the migration target in the last question. |
Reference · every term in this lesson