Documentation
Approvals
Every entrypoint that moves your money pulls it with transferFrom, which means you must
approve the contract that does the pulling — and that contract is not the same one you
are calling in two of the five cases.
Getting this wrong is the most common integration failure, and the error message does not help: you get a bare ERC-20 allowance revert naming neither the expected spender nor the amount.
The matrix#
| You are calling | Approve | Asset | Amount |
|---|---|---|---|
factory.launchToken | the factory | launchFeeToken() (USDC) | launchFee() |
launchAndBuy.launchAndBuy | the router | launchFeeToken() (USDC) and the launch's quote asset | launchFee() in USDC, quoteIn in the quote — two approvals unless the quote is USDC, when one for the sum covers both |
curve.buy | the curve | the launch's quote asset | quoteIn |
curve.sell | the curve | the memecoin (18 dp) | tokensIn |
| a graduated-pool swap | Permit2, then the router | whichever asset you spend | see below |
escrow.claim / claimToken | nothing | — | claims are pull-only |
Two things people get wrong reading that table:
- The two launch paths approve different contracts. The factory pulls the fee from
msg.senderitself, so a direct launch approves the factory. The router pulls the fee and the opening buy, so the atomic path approves the router — and approving the factory there does nothing. - Both curve legs approve the curve, but for different assets. A buy spends the quote asset; a sell spends the memecoin. The spender is the same, the token is not.
- The launch fee and the opening buy are different assets unless the launch quotes in
USDC. The fee is always
launchFee()oflaunchFeeToken()(5 USDC); the opening buy isquoteInof whateverpairTokenthe launch chose. A WETH-quoted launch with an opening buy therefore approves the router twice — USDC for the fee, WETH for the buy — and approving WETH forlaunchFee() + quoteInleaves the fee unpaid and the launch reverting.
const feeToken = await read("launchFeeToken"); // USDC
const fee = await read("launchFee"); // 5e6
await approve(feeToken, LAUNCH_AND_BUY, fee);
if (pairToken.toLowerCase() !== feeToken.toLowerCase()) {
await approve(pairToken, LAUNCH_AND_BUY, quoteIn); // the quote asset, its own decimals
} else {
await approve(feeToken, LAUNCH_AND_BUY, fee + quoteIn); // one approval when they coincide
}Pool swaps need two approvals, not one#
UniversalRouter does not pull with a plain ERC-20 allowance. It pulls through Permit2, so a pool swap needs two transactions before the swap itself:
// 1. one-time, per token: let Permit2 move this asset on your behalf
await writeContract({
address: token, abi: erc20Abi, functionName: "approve",
args: [PERMIT2, MAX_UINT160],
});
// 2. per spender, with an expiry: let the router draw from Permit2
await writeContract({
address: PERMIT2, abi: permit2Abi, functionName: "approve",
args: [token, UNIVERSAL_ROUTER, MAX_UINT160, Math.floor(Date.now() / 1000) + 30 * 86400],
});Skipping the second step reverts with AllowanceExpired(uint256) — a name that reads
like a grant lapsed when the truth is that one was never made. If you see that selector
(0xd81b2f2e), you are missing the Permit2 approval, not the ERC-20 one.
Zero launch fee needs no approval#
If factory.launchFee() returns zero there is nothing to pull, and a direct launch needs no
approval at all. Read the fee rather than assuming it — it is owner-settable and it is part of
the economics digest, so it can change between your quote and your submit.