Guides

Add and remove liquidity

Add or remove liquidity through the Motoswap router, including the ETH, permit and fee-on-transfer variants.

Both flows call MotoSwapRouter02 directly. Unlike swaps, anyone can add or remove liquidity: there is no swap allowlist check.

Read the addresses from /config

router and factory come from GET /config, and the deployment status table tracks each contract. A zero address there is unset for the current phase, so check before you send. Uniswap V2 LP tokens are added and removed on Uniswap, not here.

Setup used by every example

writeContract returns a transaction hash, not the function's return values. To read amountA, amountB and liquidity before you send, simulate first and pass the simulated request to the wallet.

import { parseAbi, type Address } from "viem";

const routerAbi = parseAbi([
  "function addLiquidity(address tokenA, address tokenB, uint256 amountADesired, uint256 amountBDesired, uint256 amountAMin, uint256 amountBMin, address to, uint256 deadline) returns (uint256 amountA, uint256 amountB, uint256 liquidity)",
  "function addLiquidityETH(address token, uint256 amountTokenDesired, uint256 amountTokenMin, uint256 amountETHMin, address to, uint256 deadline) payable returns (uint256 amountToken, uint256 amountETH, uint256 liquidity)",
  "function removeLiquidity(address tokenA, address tokenB, uint256 liquidity, uint256 amountAMin, uint256 amountBMin, address to, uint256 deadline) returns (uint256 amountA, uint256 amountB)",
  "function removeLiquidityETH(address token, uint256 liquidity, uint256 amountTokenMin, uint256 amountETHMin, address to, uint256 deadline) returns (uint256 amountToken, uint256 amountETH)",
  "function removeLiquidityWithPermit(address tokenA, address tokenB, uint256 liquidity, uint256 amountAMin, uint256 amountBMin, address to, uint256 deadline, bool approveMax, uint8 v, bytes32 r, bytes32 s) returns (uint256 amountA, uint256 amountB)",
]);
const erc20Abi = parseAbi(["function approve(address spender, uint256 amount) returns (bool)"]);

// `publicClient`, `wallet` and `acct` are built as in the quickstart. `router` comes from GET /config.
async function approve(token: Address, spender: Address, amount: bigint) {
  const hash = await wallet.writeContract({ address: token, abi: erc20Abi, functionName: "approve", args: [spender, amount] });
  await publicClient.waitForTransactionReceipt({ hash });
}
const deadline = BigInt(Math.floor(Date.now() / 1000) + 600);

Add liquidity: token and token

// 1. Approve both tokens to Router02.
await approve(tokenA, router, amountADesired);
await approve(tokenB, router, amountBDesired);

// 2. Simulate to read the amounts the pool will take, then send.
const { request, result } = await publicClient.simulateContract({
  address: router,
  abi: routerAbi,
  functionName: "addLiquidity",
  args: [tokenA, tokenB, amountADesired, amountBDesired, amountAMin, amountBMin, acct.address, deadline],
  account: acct,
});
const [amountA, amountB, liquidity] = result;
await wallet.writeContract(request);

Amounts are matched to the current reserve ratio. The router takes all of one side and the matching amount of the other, and never pulls the rest. The amount*Min floors protect against a moved pool between quote and execution.

If the pair does not exist, addLiquidity creates it through MotoSwapFactory.createPair in the same transaction. createPair has two requirements beyond Uniswap V2's:

  • Both tokens must already have code (MotoSwap: TOKEN_NOT_DEPLOYED).
  • While the factory has a quoteRegistry set, one side must be a registered quote asset (MotoSwap: NO_QUOTE). A pool between two non-quote tokens cannot be created.

For a first-time pair, the MINIMUM_LIQUIDITY = 1000 LP is minted to 0x0 (Uniswap-V2 convention) and the first deposit sets the price.

Add liquidity: token and ETH

await approve(token, router, amountTokenDesired);

const { request, result } = await publicClient.simulateContract({
  address: router,
  abi: routerAbi,
  functionName: "addLiquidityETH",
  args: [token, amountTokenDesired, amountTokenMin, amountETHMin, acct.address, deadline],
  value: amountETHDesired, // send ETH; router wraps to WETH and refunds the unused part
  account: acct,
});
const [amountToken, amountETH, liquidity] = result;
await wallet.writeContract(request);

Remove liquidity: token and token

The LP token is the pair itself. Approve the pair to the router first.

await approve(pairAddress, router, liquidity);

const { request, result } = await publicClient.simulateContract({
  address: router,
  abi: routerAbi,
  functionName: "removeLiquidity",
  args: [tokenA, tokenB, liquidity, amountAMin, amountBMin, acct.address, deadline],
  account: acct,
});
const [amountA, amountB] = result;
await wallet.writeContract(request);

Remove liquidity: token and ETH

await approve(pairAddress, router, liquidity);

const { request, result } = await publicClient.simulateContract({
  address: router,
  abi: routerAbi,
  functionName: "removeLiquidityETH",
  args: [token, liquidity, amountTokenMin, amountETHMin, acct.address, deadline],
  account: acct,
});
const [amountToken, amountETH] = result;
await wallet.writeContract(request);

Finding the pair address

Read it: factory.getPair(tokenA, tokenB). It returns the zero address when the pair does not exist.

To compute it offline, note that pairs are ERC-1967 beacon proxies deployed with CREATE2, so the init code hash is not the Uniswap V2 constant and is different on every deployment. Read it from the factory. Never hardcode it.

import { encodePacked, getContractAddress, keccak256, parseAbi, type Address } from "viem";

const initCodeHash = await publicClient.readContract({
  address: factory,
  abi: parseAbi(["function pairCodeHash() view returns (bytes32)"]),
  functionName: "pairCodeHash",
});

function pairFor(tokenA: Address, tokenB: Address): Address {
  const [token0, token1] = tokenA.toLowerCase() < tokenB.toLowerCase() ? [tokenA, tokenB] : [tokenB, tokenA];
  return getContractAddress({
    opcode: "CREATE2",
    from: factory,
    salt: keccak256(encodePacked(["address", "address"], [token0, token1])),
    bytecodeHash: initCodeHash,
  });
}

INIT_CODE_PAIR_HASH() is an alias that returns the same value.

Permit variants (skip the approve tx)

The pair is an EIP-2612 token (MotoSwapERC20), so you can sign the LP approval off-chain and pass it in one call:

import { parseSignature } from "viem";

const nonce = await publicClient.readContract({
  address: pairAddress,
  abi: parseAbi(["function nonces(address) view returns (uint256)"]),
  functionName: "nonces",
  args: [acct.address],
});

const signature = await wallet.signTypedData({
  account: acct,
  domain: { name: "Motoswap V1", version: "1", chainId: config.chainId, verifyingContract: pairAddress },
  types: {
    Permit: [
      { name: "owner", type: "address" },
      { name: "spender", type: "address" },
      { name: "value", type: "uint256" },
      { name: "nonce", type: "uint256" },
      { name: "deadline", type: "uint256" },
    ],
  },
  primaryType: "Permit",
  message: { owner: acct.address, spender: router, value: liquidity, nonce, deadline },
});
const { v, r, s } = parseSignature(signature);

await wallet.writeContract({
  address: router,
  abi: routerAbi,
  functionName: "removeLiquidityWithPermit",
  args: [tokenA, tokenB, liquidity, amountAMin, amountBMin, acct.address, deadline, false, Number(v), r, s],
});

The domain is name = "Motoswap V1", version = "1", the chain id, and the pair address. DOMAIN_SEPARATOR() is recomputed on every call, so it is fork-safe. With approveMax = true, sign value = 2^256 - 1 instead of liquidity. The permit and the router call share one deadline.

The router wraps the permit call in a try/catch. If someone front-runs your permit, the allowance is already set and the removal still goes through.

Fee-on-transfer variants

Which variants exist

Only ETH-out remove has FoT variants:

  • removeLiquidityETHSupportingFeeOnTransferTokens(token, liquidity, amountTokenMin, amountETHMin, to, deadline)
  • removeLiquidityETHWithPermitSupportingFeeOnTransferTokens(token, liquidity, amountTokenMin, amountETHMin, to, deadline, approveMax, v, r, s)

Both return amountETH only.

When to use them

Use these when the token taxes its own transfers. They send you the token balance the router actually received instead of the nominal amount. Regular removeLiquidity works for every other token.

Events emitted

event Mint(address indexed sender, uint256 amount0, uint256 amount1);
event Burn(address indexed sender, uint256 amount0, uint256 amount1, address indexed to);
event Sync(uint112 reserve0, uint112 reserve1);
event Transfer(address indexed from, address indexed to, uint256 value);   // LP token

Mint fires on add-liquidity and Burn on remove-liquidity. sender is the router on both. Mint has no recipient field, so indexers take it from the LP Transfer from 0x0. Read the feeTo log order before you do: a fee mint to factory.feeTo() can precede the user's transfer.

Common errors

ErrorCauseFix
TransferHelper: TRANSFER_FROM_FAILEDRouter could not pull a token. Almost always a missing or too-small approve to the router, sometimes an insufficient balance. The most common first-run error.Run the approve step for BOTH tokens (or the pair, on remove) before calling.
MotoSwapRouter: DESIRED_BELOW_MINamountADesired < amountAMin or amountBDesired < amountBMin.Fix the arguments. A min can never exceed its desired amount.
MotoSwapRouter: INSUFFICIENT_A_AMOUNT / ..._B_AMOUNTActual side taken < amount*Min.Re-quote, or widen slippage.
MotoSwap: TOKEN_NOT_DEPLOYEDAuto-create: one of the tokens has no code.Deploy the token first.
MotoSwap: NO_QUOTEAuto-create: neither token is a registered quote asset.Pair against WETH, USDC, USDT or MOTO.
MotoSwap: INSUFFICIENT_LIQUIDITY_MINTEDMinted LP is zero. On a first add where sqrt(amount0 * amount1) is below MINIMUM_LIQUIDITY, the pair reverts with an arithmetic panic (0x11) instead.Add more liquidity.
MotoSwap: INSUFFICIENT_LIQUIDITY_BURNEDBurnt LP too small to receive tokens (pair-level string).Burn more LP.
MotoSwapRouter: EXPIREDdeadline < block.timestamp.Use a later deadline.
Panic 0x11 on removeThe LP transferFrom underflowed: no LP allowance to the router, or not enough LP. MotoSwapERC20 uses checked math and does not return false, so MotoSwapRouter: LP_TRANSFER_FAILED is not what you see.Approve the pair to the router, or use a permit variant.

Note the two prefixes if you string-match: router-level reverts carry MotoSwapRouter:, pair-level reverts carry MotoSwap:.

When to use the Zap instead

If you only have one side of a pair, MotoSwapZap.zapInToken / zapInETH swaps optimally and adds liquidity in a single tx (with dust refund). See zap-in.

On this page