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
quoteRegistryset, 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 tokenMint 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
| Error | Cause | Fix |
|---|---|---|
TransferHelper: TRANSFER_FROM_FAILED | Router 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_MIN | amountADesired < amountAMin or amountBDesired < amountBMin. | Fix the arguments. A min can never exceed its desired amount. |
MotoSwapRouter: INSUFFICIENT_A_AMOUNT / ..._B_AMOUNT | Actual side taken < amount*Min. | Re-quote, or widen slippage. |
MotoSwap: TOKEN_NOT_DEPLOYED | Auto-create: one of the tokens has no code. | Deploy the token first. |
MotoSwap: NO_QUOTE | Auto-create: neither token is a registered quote asset. | Pair against WETH, USDC, USDT or MOTO. |
MotoSwap: INSUFFICIENT_LIQUIDITY_MINTED | Minted 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_BURNED | Burnt LP too small to receive tokens (pair-level string). | Burn more LP. |
MotoSwapRouter: EXPIRED | deadline < block.timestamp. | Use a later deadline. |
Panic 0x11 on remove | The 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.