Skip to main content

V3 Add Liquidity Example

A complete example of minting a V3 concentrated liquidity position with a custom price range.

Full Code

import { ethers } from 'ethers';

// === Configuration (Mainnet) ===
const RPC_URL = 'https://evmrpc.kasplex.org';
const V3_FACTORY = '0x0dfb1Bb755d872EA1fa4d95E4ad0c2E6317Ce9B9';
const V3_POSITION_MANAGER = '0x343b244bEDF133D57C61b241557bF29AA32ea4F9';

// === ABIs ===
const ERC20_ABI = [
  'function approve(address,uint256) returns (bool)',
  'function allowance(address,address) view returns (uint256)',
  'function decimals() view returns (uint8)',
  'function symbol() view returns (string)',
];

const FACTORY_ABI = [
  'function getPool(address,address,uint24) view returns (address)',
];

const POOL_ABI = [
  'function slot0() view returns (uint160, int24, uint16, uint16, uint16, uint8, bool)',
  'function tickSpacing() view returns (int24)',
  'function token0() view returns (address)',
  'function token1() view returns (address)',
  'function liquidity() view returns (uint128)',
];

const PM_ABI = [
  'function mint(tuple(address token0, address token1, uint24 fee, int24 tickLower, int24 tickUpper, uint256 amount0Desired, uint256 amount1Desired, uint256 amount0Min, uint256 amount1Min, address recipient, uint256 deadline)) payable returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1)',
  'function positions(uint256 tokenId) view returns (uint96, address, address, address, uint24, int24, int24, uint128, uint256, uint256, uint128, uint128)',
];

// === Helpers ===
function nearestUsableTick(tick: number, tickSpacing: number): number {
  return Math.round(tick / tickSpacing) * tickSpacing;
}

function sortTokens(a: string, b: string): [string, string] {
  return a.toLowerCase() < b.toLowerCase() ? [a, b] : [b, a];
}

/**
 * Create a V3 liquidity position.
 *
 * @param signer        - Connected ethers Signer
 * @param tokenA        - First token address
 * @param tokenB        - Second token address
 * @param fee           - Fee tier (100, 500, 3000, or 10000)
 * @param amount0       - Desired amount of token0 (raw units)
 * @param amount1       - Desired amount of token1 (raw units)
 * @param rangePercent  - Price range as ± percentage (e.g., 10 for ±10%)
 */
async function addLiquidityV3(
  signer: ethers.Signer,
  tokenA: string,
  tokenB: string,
  fee: number,
  amount0: bigint,
  amount1: bigint,
  rangePercent: number = 10
) {
  const provider = signer.provider!;
  const userAddress = await signer.getAddress();
  const deadline = Math.floor(Date.now() / 1000) + 1200;

  // Sort tokens (token0 < token1 by address)
  const [token0, token1] = sortTokens(tokenA, tokenB);
  const isSwapped = token0.toLowerCase() !== tokenA.toLowerCase();
  if (isSwapped) [amount0, amount1] = [amount1, amount0];

  // --- Get pool state ---
  const factory = new ethers.Contract(V3_FACTORY, FACTORY_ABI, provider);
  const poolAddress = await factory.getPool(token0, token1, fee);

  if (poolAddress === ethers.ZeroAddress) {
    throw new Error(`No pool exists for this pair with fee ${fee}`);
  }

  const pool = new ethers.Contract(poolAddress, POOL_ABI, provider);
  const [sqrtPriceX96, currentTick] = await pool.slot0();
  const tickSpacing = Number(await pool.tickSpacing());

  console.log(`Pool: ${poolAddress}`);
  console.log(`Current tick: ${currentTick}`);
  console.log(`Tick spacing: ${tickSpacing}`);

  // --- Calculate tick range ---
  // Convert percentage to approximate tick offset
  // 1% price change ≈ 100 ticks (since 1.0001^100 ≈ 1.01)
  const tickOffset = Math.floor(rangePercent * 100);

  const tickLower = nearestUsableTick(Number(currentTick) - tickOffset, tickSpacing);
  const tickUpper = nearestUsableTick(Number(currentTick) + tickOffset, tickSpacing);

  console.log(`Range: tick [${tickLower}, ${tickUpper}]`);

  // --- Approve tokens ---
  console.log('Approving tokens...');
  const token0Contract = new ethers.Contract(token0, ERC20_ABI, signer);
  const token1Contract = new ethers.Contract(token1, ERC20_ABI, signer);

  const allow0 = await token0Contract.allowance(userAddress, V3_POSITION_MANAGER);
  const allow1 = await token1Contract.allowance(userAddress, V3_POSITION_MANAGER);

  if (allow0 < amount0) {
    await (await token0Contract.approve(V3_POSITION_MANAGER, ethers.MaxUint256)).wait();
  }
  if (allow1 < amount1) {
    await (await token1Contract.approve(V3_POSITION_MANAGER, ethers.MaxUint256)).wait();
  }

  // --- Mint position ---
  console.log('Minting position...');
  const pm = new ethers.Contract(V3_POSITION_MANAGER, PM_ABI, signer);

  const mintParams = {
    token0,
    token1,
    fee,
    tickLower,
    tickUpper,
    amount0Desired: amount0,
    amount1Desired: amount1,
    amount0Min: 0n, // Set to 0 for simplicity; use slippage in production
    amount1Min: 0n,
    recipient: userAddress,
    deadline,
  };

  const tx = await pm.mint(mintParams);
  const receipt = await tx.wait();

  console.log(`Position minted. Tx: ${receipt!.hash}`);

  // Parse tokenId from events (simplified)
  for (const log of receipt!.logs) {
    try {
      const parsed = pm.interface.parseLog({ topics: [...log.topics], data: log.data });
      if (parsed && parsed.name === 'IncreaseLiquidity') {
        console.log(`Token ID: ${parsed.args.tokenId}`);
        console.log(`Liquidity: ${parsed.args.liquidity}`);
        console.log(`Amount0: ${parsed.args.amount0}`);
        console.log(`Amount1: ${parsed.args.amount1}`);
      }
    } catch {
      // Not a PM event, skip
    }
  }
}

// === Usage ===
async function main() {
  const provider = new ethers.JsonRpcProvider(RPC_URL);
  const signer = new ethers.Wallet('YOUR_PRIVATE_KEY', provider);

  const TOKEN_A = '0xB190a6A7fC2873f1Abf145279eD664348d5Ef630';
  const TOKEN_B = '0x3Ac3B30b7f18AEFD4590D7FE4d9C5944aaeB7220';

  await addLiquidityV3(
    signer,
    TOKEN_A,
    TOKEN_B,
    3000,                       // 0.3% fee tier
    ethers.parseEther('10'),    // 10 token0
    ethers.parseEther('10'),    // 10 token1
    10                           // ±10% range
  );
}

main().catch(console.error);

Key Points

  1. Token order matters: token0 must have a lower address than token1. The helper sortTokens() handles this.
  2. Tick alignment: tickLower and tickUpper must be multiples of tickSpacing. Use nearestUsableTick().
  3. Fee tier: Must match an existing pool. Check with factory.getPool() first.
  4. Slippage: Set amount0Min / amount1Min to non-zero values in production.
  5. Native KAS: If one token is KAS, send it as { value: kasAmount } in the mint call.

Managing the Position

After minting, use the tokenId for:
// Collect fees
await pm.collect({
  tokenId,
  recipient: userAddress,
  amount0Max: ethers.MaxUint128,
  amount1Max: ethers.MaxUint128,
});

// Remove liquidity
await pm.decreaseLiquidity({
  tokenId,
  liquidity: liquidityAmount,
  amount0Min: 0,
  amount1Min: 0,
  deadline,
});

// Then collect the tokens
await pm.collect({ tokenId, recipient: userAddress, amount0Max: ethers.MaxUint128, amount1Max: ethers.MaxUint128 });