跳转到主要内容

V3 添加流动性示例

一个铸造 V3 集中流动性头寸并设置自定义价格范围的完整示例。

完整代码

import { ethers } from 'ethers';

// === 配置(主网)===
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)',
];

// === 辅助函数 ===
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];
}

/**
 * 创建一个 V3 流动性头寸。
 *
 * @param signer        - 已连接的 ethers Signer
 * @param tokenA        - 第一个代币地址
 * @param tokenB        - 第二个代币地址
 * @param fee           - 手续费等级(100、500、3000 或 10000)
 * @param amount0       - 期望的 token0 数量(原始单位)
 * @param amount1       - 期望的 token1 数量(原始单位)
 * @param rangePercent  - 价格范围百分比(例如 10 表示 ±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;

  // 排序代币(token0 地址 < token1 地址)
  const [token0, token1] = sortTokens(tokenA, tokenB);
  const isSwapped = token0.toLowerCase() !== tokenA.toLowerCase();
  if (isSwapped) [amount0, amount1] = [amount1, amount0];

  // --- 获取池状态 ---
  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(`此交易对在手续费等级 ${fee} 下不存在池`);
  }

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

  console.log(`池地址:${poolAddress}`);
  console.log(`当前 tick:${currentTick}`);
  console.log(`Tick 间距:${tickSpacing}`);

  // --- 计算 tick 范围 ---
  // 将百分比转换为近似 tick 偏移量
  // 1% 的价格变化 ≈ 100 个 tick(因为 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(`范围:tick [${tickLower}, ${tickUpper}]`);

  // --- 授权代币 ---
  console.log('正在授权代币...');
  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();
  }

  // --- 铸造头寸 ---
  console.log('正在铸造头寸...');
  const pm = new ethers.Contract(V3_POSITION_MANAGER, PM_ABI, signer);

  const mintParams = {
    token0,
    token1,
    fee,
    tickLower,
    tickUpper,
    amount0Desired: amount0,
    amount1Desired: amount1,
    amount0Min: 0n, // 为简化设为 0;生产环境请使用滑点保护
    amount1Min: 0n,
    recipient: userAddress,
    deadline,
  };

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

  console.log(`头寸已铸造。交易哈希:${receipt!.hash}`);

  // 从事件中解析 tokenId(简化版)
  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(`流动性:${parsed.args.liquidity}`);
        console.log(`Amount0:${parsed.args.amount0}`);
        console.log(`Amount1:${parsed.args.amount1}`);
      }
    } catch {
      // 非 PM 事件,跳过
    }
  }
}

// === 使用示例 ===
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% 手续费等级
    ethers.parseEther('10'),    // 10 个 token0
    ethers.parseEther('10'),    // 10 个 token1
    10                           // ±10% 范围
  );
}

main().catch(console.error);

要点

  1. 代币顺序很重要token0 的地址必须小于 token1。辅助函数 sortTokens() 会处理排序。
  2. Tick 对齐tickLowertickUpper 必须是 tickSpacing 的整数倍。使用 nearestUsableTick()
  3. 手续费等级:必须与已有池匹配。先用 factory.getPool() 检查。
  4. 滑点保护:生产环境中请将 amount0Min / amount1Min 设为非零值。
  5. 原生 KAS:如果其中一个代币是 KAS,在 mint 调用中以 { value: kasAmount } 发送。

管理头寸

铸造后,使用 tokenId 进行以下操作:
// 领取手续费
await pm.collect({
  tokenId,
  recipient: userAddress,
  amount0Max: ethers.MaxUint128,
  amount1Max: ethers.MaxUint128,
});

// 移除流动性
await pm.decreaseLiquidity({
  tokenId,
  liquidity: liquidityAmount,
  amount0Min: 0,
  amount1Min: 0,
  deadline,
});

// 然后提取代币
await pm.collect({ tokenId, recipient: userAddress, amount0Max: ethers.MaxUint128, amount1Max: ethers.MaxUint128 });