Documentation
Graduation
When the curve's sellable allocation reaches zero — the same moment its real reserve reaches the graduation threshold — the launch graduates. That is two steps, and understanding why they are separate is the difference between a working integration and a stuck token.
The two steps#
factory.graduate(token) phase 0 -> 1 sweeps the curve into the factory
factory.createGraduatedPool(token) phase 1 -> 2 seeds the V4 pool, locks the positionBoth are permissionless — anyone may call either. Both normally run automatically inside
the buy that crosses the threshold, wrapped in try/catch so that a failure to graduate
never fails the trade.
The gas trap#
That try/catch is also the trap.
Seeding the pool costs roughly 900,000 gas on its own. Under EIP-150's 63/64 rule the
nested call only receives 63/64 of whatever the buy has left. If the buy was sized by a naive
eth_estimateGas, there is not enough left and the seed dies.
And estimation cannot discover this. The estimator simulates the whole transaction
including the try/catch; the seed fails inside it; the catch swallows the failure; the
transaction succeeds. So the estimator returns a limit sized for the seed-fails path —
which is by construction never enough for the seed-succeeds path.
This is deterministic, not flaky. With naive estimation the auto-seed fails every time, and reports success while doing it.
Measured on Arc testnet, two launches identical except the gas limit:
| Gas limit | Result |
|---|---|
1,154,267 (from estimateGas) | AutoSeedFailed(token, 27828) — stuck in phase 1 |
| 3,000,000 (explicit) | seeded in the same transaction |
The shortfall was about 150,000 gas.
Watch for the failure events#
The curve tells you when it happened:
event AutoGraduationFailed(address indexed token, uint256 gasRemaining);
event AutoSeedFailed(address indexed token, uint256 gasRemaining);Both are the signal to finish the job manually. Alert on them, and have a keeper call the matching entrypoint:
// finish a launch stuck in phase 1
const gas = await client.estimateContractGas({
address: FACTORY, abi: factoryAbi, functionName: "createGraduatedPool", args: [token], account,
});
await walletClient.writeContract({
address: FACTORY, abi: factoryAbi, functionName: "createGraduatedPool",
args: [token], gas: gas * 2n,
});Estimation is reliable here — called directly there is no try/catch hiding the cost.
A launch in phase 1 is not broken and nothing is lost: the swept reserves sit safely in the factory and the seed is retryable by anyone, indefinitely. It simply has no venue until somebody calls it.
What the seed does#
- Computes the token side as
sweptTokens · sweptQuote / (sweptQuote + phantomQuote). - Permanently locks the remainder in the launch locker — that supply is burned in every sense that matters; the locker has no withdrawal path for anyone, including its owner.
- Initialises the V4 pool at the curve's closing price, with the foci hook attached.
- Mints a full-range position directly to the locker, which is why the liquidity can never be pulled.
At the shipped curve shape, 71.43% of supply is sold on the curve, 20.41% is seeded into the pool, and 8.16% is burned forever.
Checking the phase#
const launch = await client.readContract({
address: FACTORY, abi: factoryAbi, functionName: "getLaunchedToken", args: [token],
});
// 0 curve · 1 swept (no venue) · 2 pool · 3 rescuedgetLaunchedToken returns a zeroed struct for an unknown token rather than reverting —
check .exists before trusting .phase, or an unknown address looks like a live curve.