Create
Documentation
Guides

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 callingApproveAssetAmount
factory.launchTokenthe factorylaunchFeeToken() (USDC)launchFee()
launchAndBuy.launchAndBuythe routerlaunchFeeToken() (USDC) and the launch's quote assetlaunchFee() in USDC, quoteIn in the quote — two approvals unless the quote is USDC, when one for the sum covers both
curve.buythe curvethe launch's quote assetquoteIn
curve.sellthe curvethe memecoin (18 dp)tokensIn
a graduated-pool swapPermit2, then the routerwhichever asset you spendsee below
escrow.claim / claimTokennothingclaims 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.sender itself, 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() of launchFeeToken() (5 USDC); the opening buy is quoteIn of whatever pairToken the 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 for launchFee() + quoteIn leaves the fee unpaid and the launch reverting.
ts
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:

ts
// 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.

Tip
Check both allowances before offering a swap button. permit2.allowance(owner, token, spender) returns (amount, expiration, nonce) — treat an expiry in the past as unapproved, not as approved-with-zero.

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.