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 factoryquote asset (USDC)launchFee()
launchAndBuy.launchAndBuythe routerquote asset (USDC)launchFee() + quoteIn — one approval covers both
curve.buythe curvequote asset (USDC)quoteIn
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.

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.