Developers

Integration

View as Markdown

This page builds the same calls the Giwater app makes, in four files: a client, a quote, a swap and a liquidity deposit. Every example targets the BTC/USDC 0.30% pool and runs as written with tsx or any TypeScript runtime that supports top-level await. The viem tab and the ethers v6 tab send identical transactions.

The examples send real transactions on GIWA Sepolia. Use a key that holds only testnet funds, claim test tokens from the Faucet, and never put a key in source code.

Setup

setup.ts creates the clients and holds the constants the other files import: the contract addresses from Contracts, the two tokens, and the pool key.

A pool is identified by its whole key, not by its token pair alone. The key has six fields: the two currencies sorted by address (currency0 is the lower one), the hook contract (the zero address here), the pool manager, the LP fee, and parameters, a 32-byte word that packs the tick spacing into bits 16 and above and the hook callback flags into the low 16 bits. Change any field and you address a different pool, or one that does not exist. The pool ID is the keccak256 of the ABI-encoded key.

fee is the LP fee in hundredths of a basis point. The seeded pools set it so that LP fee and protocol fee together make the advertised tier: here 2011 plus the protocol fee comes to 0.2999%, shown as 0.30%.

setup.ts
import { createPublicClient, createWalletClient, http, type Hex } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { giwaSepolia } from "viem/chains"; // chain 91342, RPC https://sepolia-rpc.giwa.io

// Never hard-code a key: load a testnet-only key from the environment.
export const account = privateKeyToAccount(process.env.PRIVATE_KEY as Hex);

export const publicClient = createPublicClient({ chain: giwaSepolia, transport: http() });
export const walletClient = createWalletClient({ account, chain: giwaSepolia, transport: http() });

// Addresses from the Contracts page.
export const CONTRACTS = {
  clPoolManager: "0x1B24bDE0c2b7fd77450A48C4f4b5db0eA4AD6bDc",
  clPositionManager: "0xC818C063CaBEB35296c05cC7B06d9Ff38Be2a9db",
  clQuoter: "0xf93cEFbd66AB53de1c02fBeC3b7a7b7A85743C0b",
  universalRouter: "0xe958049Fee2a61F1e96243239c4d76F91B9B19b5",
  permit2: "0x000000000022D473030F116dDEE9F6B43aC78BA3",
} as const;

export const BTC = "0x1f6e6ffb0fC879CEC28830531C6Ec710F6110A7E"; // 8 decimals
export const USDC = "0xC7aA29e2FcB71c6396650F8Cc76dDA20714A5814"; // 6 decimals

// The BTC/USDC 0.30% pool. currency0 < currency1, hookless, tick spacing 60.
export const POOL_KEY = {
  currency0: BTC,
  currency1: USDC,
  hooks: "0x0000000000000000000000000000000000000000",
  poolManager: CONTRACTS.clPoolManager,
  fee: 2011, // LP fee in hundredths of a bip; with the protocol fee the total is 0.30%
  parameters: "0x00000000000000000000000000000000000000000000000000000000003c0000", // tickSpacing << 16
} as const;

Quote a Swap

CLQuoter.quoteExactInputSingle runs the swap against the pool's current state and returns the output it would produce. It reverts internally to discard that state change, so the function is marked non-view: always call it (simulateContract in viem, staticCall in ethers), never send it.

zeroForOne gives the direction. Selling currency0 for currency1 is true; selling USDC, which is currency1 in this pool, is false. exactAmount is in the token's smallest unit, so 100 USDC at 6 decimals is 100000000.

A quote is valid only for the block it was read at. The swap below sets its minimum output from this quote, so quote right before you swap.

quote.ts
import { formatUnits, parseAbi, parseUnits } from "viem";
import { CONTRACTS, POOL_KEY, publicClient, USDC } from "./setup";

const quoterAbi = parseAbi([
  "struct PoolKey { address currency0; address currency1; address hooks; address poolManager; uint24 fee; bytes32 parameters; }",
  "struct QuoteExactSingleParams { PoolKey poolKey; bool zeroForOne; uint128 exactAmount; bytes hookData; }",
  "function quoteExactInputSingle(QuoteExactSingleParams params) returns (uint256 amountOut, uint256 gasEstimate)",
]);

export const amountIn = parseUnits("100", 6); // sell 100 USDC
// USDC is currency1, so this swap moves 1 -> 0.
const zeroForOne = USDC.toLowerCase() === POOL_KEY.currency0.toLowerCase();

// The quoter simulates the swap and reverts with the result, so call it, never send it.
const { result } = await publicClient.simulateContract({
  address: CONTRACTS.clQuoter,
  abi: quoterAbi,
  functionName: "quoteExactInputSingle",
  args: [{ poolKey: POOL_KEY, zeroForOne, exactAmount: amountIn, hookData: "0x" }],
});
export const amountOut = result[0];

console.log("100 USDC ->", formatUnits(amountOut, 8), "BTC");

Execute a Swap

A swap takes three transactions the first time and one after that.

Permit2. The Universal Router never holds a direct ERC-20 allowance. Each token is approved once to Permit2, and Permit2 then grants the router a scoped allowance with an amount and an expiry. One approval per token serves every contract that pulls through Permit2, and an allowance that expires on its own limits what a compromised spender could take. The examples call Permit2.approve directly. The app instead signs an EIP-712 permit and sends it inside the same execute call, which saves one transaction; both result in the same allowance.

The plan. The router's INFI_SWAP command (0x10) carries a plan: a byte string of action IDs and an array of ABI-encoded parameters, one per action. The swap uses three actions:

  • CL_SWAP_EXACT_IN (0x07) runs the swap along a path, here a single pool.
  • SETTLE_ALL (0x0c) pays the Vault the USDC the swap owes, pulled from your wallet through Permit2.
  • TAKE (0x0e) withdraws the BTC the swap produced. MSG_SENDER (0x…01) is the router's shorthand for the caller.

Nothing moves while the swap action runs. The Vault only records what each currency owes and is owed, and when the plan finishes, still inside execute, it checks that every balance has been settled, reverting the whole call if any has not. That is why the swap comes first and the payment and withdrawal follow it in the same plan.

Slippage and deadline. amountOutMinimum is the quoted output minus your slippage tolerance, 0.5% here. If the price moves further before the transaction lands, the swap reverts rather than filling at the worse price. The deadline, 20 minutes after the latest block, makes a transaction that stays pending too long revert instead of executing at a price you no longer expect.

swap.ts
import { encodeAbiParameters, erc20Abi, maxUint256, parseAbi, parseAbiParameters, type Hex } from "viem";
import { BTC, CONTRACTS, POOL_KEY, publicClient, USDC, walletClient } from "./setup";
import { amountIn, amountOut } from "./quote";

const permit2Abi = parseAbi([
  "function approve(address token, address spender, uint160 amount, uint48 expiration)",
]);
const routerAbi = parseAbi([
  "function execute(bytes commands, bytes[] inputs, uint256 deadline) payable",
]);

const block = await publicClient.getBlock();

// 1. Let Permit2 move USDC. Once per token; skip it when the allowance already covers you.
const approveHash = await walletClient.writeContract({
  address: USDC,
  abi: erc20Abi,
  functionName: "approve",
  args: [CONTRACTS.permit2, maxUint256],
});
await publicClient.waitForTransactionReceipt({ hash: approveHash });

// 2. Let the Universal Router pull this amount through Permit2, for 30 days.
const permitHash = await walletClient.writeContract({
  address: CONTRACTS.permit2,
  abi: permit2Abi,
  functionName: "approve",
  args: [USDC, CONTRACTS.universalRouter, amountIn, Number(block.timestamp + 30n * 86_400n)],
});
await publicClient.waitForTransactionReceipt({ hash: permitHash });

// 3. The Infinity plan: swap, pay what the swap owes, take what it produced.
const CL_SWAP_EXACT_IN = "07";
const SETTLE_ALL = "0c";
const TAKE = "0e";
const MSG_SENDER = "0x0000000000000000000000000000000000000001"; // the router reads this as "the caller"

const slippageBps = 50n; // 0.5%
const amountOutMinimum = (amountOut * (10_000n - slippageBps)) / 10_000n;

const swap = encodeAbiParameters(
  parseAbiParameters(
    "(address currencyIn, (address intermediateCurrency, uint24 fee, address hooks, address poolManager, bytes hookData, bytes32 parameters)[] path, uint128 amountIn, uint128 amountOutMinimum)",
  ),
  [
    {
      currencyIn: USDC,
      path: [
        {
          intermediateCurrency: BTC,
          fee: POOL_KEY.fee,
          hooks: POOL_KEY.hooks,
          poolManager: POOL_KEY.poolManager,
          hookData: "0x",
          parameters: POOL_KEY.parameters,
        },
      ],
      amountIn,
      amountOutMinimum,
    },
  ],
);
const settle = encodeAbiParameters(parseAbiParameters("address, uint256"), [USDC, amountIn]);
const take = encodeAbiParameters(parseAbiParameters("address, address, uint256"), [
  BTC,
  MSG_SENDER,
  0n,
]);
const plan = encodeAbiParameters(parseAbiParameters("bytes, bytes[]"), [
  ("0x" + CL_SWAP_EXACT_IN + SETTLE_ALL + TAKE) as Hex,
  [swap, settle, take],
]);

// 4. One Universal Router command, INFI_SWAP (0x10), carrying the plan. Reverts after 20 minutes.
const INFI_SWAP = "0x10";
const hash = await walletClient.writeContract({
  address: CONTRACTS.universalRouter,
  abi: routerAbi,
  functionName: "execute",
  args: [INFI_SWAP, [plan], block.timestamp + 1_200n],
});
console.log("swap:", (await publicClient.waitForTransactionReceipt({ hash })).status);

Add Liquidity

A position is minted through CLPositionManager.modifyLiquidities, which takes a plan in the same format as the router and a deadline. The plan mints the position (CL_MINT_POSITION, 0x02), then closes each currency (CLOSE_CURRENCY, 0x12), paying the Vault what the mint owes. The position manager pulls the tokens through Permit2, so both are approved the same way as for a swap, with the position manager as the Permit2 spender.

Tick alignment. A position's lower and upper ticks must both be multiples of the pool's tick spacing, 60 for this pool, or the mint reverts. The example takes about 1,000 ticks (roughly 10% in price) on each side of the current tick, rounds the lower end down and the upper end up to the spacing.

Amounts. A mint is specified in liquidity, not token amounts. The example fixes the USDC side at 100 USDC, derives the liquidity that amount buys between the current price and the lower bound, and computes the BTC that liquidity needs. amount0Max and amount1Max cap what the position manager may take: the USDC exactly, and the BTC plus 0.5% in case the price moves before the transaction lands. getSqrtPriceAtTick reproduces the pool's own tick math, so the amounts round the same way the contract does.

Native ETH and WETH9. None of the seeded pools uses native ETH, but Infinity supports it as a pool currency: its currency is the zero address, it always sorts as currency0, and it is paid with the transaction's value instead of an approval. WETH9 (0x4200…0006) is an ordinary ERC-20 and forms separate pools. A pool with native ETH and one with WETH9 for the same pair are two different pools, with different keys and different liquidity.

add-liquidity.ts
import {
  encodeAbiParameters,
  erc20Abi,
  keccak256,
  maxUint256,
  parseAbi,
  parseAbiParameters,
  parseUnits,
  type Hex,
} from "viem";
import { account, BTC, CONTRACTS, POOL_KEY, publicClient, USDC, walletClient } from "./setup";

const poolManagerAbi = parseAbi([
  "function getSlot0(bytes32 id) view returns (uint160 sqrtPriceX96, int24 tick, uint24 protocolFee, uint24 lpFee)",
]);
const permit2Abi = parseAbi([
  "function approve(address token, address spender, uint160 amount, uint48 expiration)",
]);
const positionManagerAbi = parseAbi([
  "function modifyLiquidities(bytes payload, uint256 deadline) payable",
]);
const POOL_KEY_ABI =
  "(address currency0, address currency1, address hooks, address poolManager, uint24 fee, bytes32 parameters)";

// sqrt(1.0001^tick) * 2^96, rounded up exactly as the pool's TickMath rounds it.
function getSqrtPriceAtTick(tick: number): bigint {
  const absTick = BigInt(Math.abs(tick));
  let ratio = absTick & 1n ? 0xfffcb933bd6fad37aa2d162d1a594001n : 0x100000000000000000000000000000000n;
  const multipliers = [
    0xfff97272373d413259a46990580e213an, 0xfff2e50f5f656932ef12357cf3c7fdccn,
    0xffe5caca7e10e4e61c3624eaa0941cd0n, 0xffcb9843d60f6159c9db58835c926644n,
    0xff973b41fa98c081472e6896dfb254c0n, 0xff2ea16466c96a3843ec78b326b52861n,
    0xfe5dee046a99a2a811c461f1969c3053n, 0xfcbe86c7900a88aedcffc83b479aa3a4n,
    0xf987a7253ac413176f2b074cf7815e54n, 0xf3392b0822b70005940c7a398e4b70f3n,
    0xe7159475a2c29b7443b29c7fa6e889d9n, 0xd097f3bdfd2022b8845ad8f792aa5825n,
    0xa9f746462d870fdf8a65dc1f90e061e5n, 0x70d869a156d2a1b890bb3df62baf32f7n,
    0x31be135f97d08fd981231505542fcfa6n, 0x9aa508b5b7a84e1c677de54f3e99bc9n,
    0x5d6af8dedb81196699c329225ee604n, 0x2216e584f5fa1ea926041bedfe98n,
    0x48a170391f7dc42444e8fa2n,
  ];
  multipliers.forEach((multiplier, i) => {
    if (absTick & (2n << BigInt(i))) ratio = (ratio * multiplier) >> 128n;
  });
  if (tick > 0) ratio = maxUint256 / ratio;
  return (ratio >> 32n) + (ratio % (1n << 32n) === 0n ? 0n : 1n);
}

const block = await publicClient.getBlock();
const poolId = keccak256(encodeAbiParameters(parseAbiParameters(POOL_KEY_ABI), [POOL_KEY]));
const [sqrtPriceX96, tick] = await publicClient.readContract({
  address: CONTRACTS.clPoolManager,
  abi: poolManagerAbi,
  functionName: "getSlot0",
  args: [poolId],
  blockNumber: block.number,
});

// 1. A range of roughly +-10% around the price. Both ends must be multiples of the tick spacing.
const tickSpacing = Number((BigInt(POOL_KEY.parameters) >> 16n) & 0xffffffn); // 60
const tickLower = Math.floor((tick - 1_000) / tickSpacing) * tickSpacing;
const tickUpper = Math.ceil((tick + 1_000) / tickSpacing) * tickSpacing;

// 2. Liquidity for 100 USDC (token1), and the BTC (token0) it pairs with at the current price.
const amount1 = parseUnits("100", 6);
const sqrtLower = getSqrtPriceAtTick(tickLower);
const sqrtUpper = getSqrtPriceAtTick(tickUpper);
const liquidity = (amount1 * (1n << 96n)) / (sqrtPriceX96 - sqrtLower);
const amount0Numerator = (liquidity << 96n) * (sqrtUpper - sqrtPriceX96);
const amount0Denominator = sqrtUpper * sqrtPriceX96;
const amount0 = (amount0Numerator + amount0Denominator - 1n) / amount0Denominator;

// Caps the position manager may pull: the exact USDC, and the BTC plus 0.5% for price drift.
const slippageBps = 50n;
const amount0Max = (amount0 * (10_000n + slippageBps) + 9_999n) / 10_000n;
const amount1Max = amount1;

// 3. Approve both tokens to Permit2, then let the position manager pull the caps through it.
for (const [token, amount] of [
  [BTC, amount0Max],
  [USDC, amount1Max],
] as const) {
  const approveHash = await walletClient.writeContract({
    address: token,
    abi: erc20Abi,
    functionName: "approve",
    args: [CONTRACTS.permit2, maxUint256],
  });
  await publicClient.waitForTransactionReceipt({ hash: approveHash });
  const permitHash = await walletClient.writeContract({
    address: CONTRACTS.permit2,
    abi: permit2Abi,
    functionName: "approve",
    args: [token, CONTRACTS.clPositionManager, amount, Number(block.timestamp + 30n * 86_400n)],
  });
  await publicClient.waitForTransactionReceipt({ hash: permitHash });
}

// 4. The plan: mint the position, then close both currencies, paying what the mint owes.
const CL_MINT_POSITION = "02";
const CLOSE_CURRENCY = "12";
const mint = encodeAbiParameters(
  parseAbiParameters([
    "(address currency0, address currency1, address hooks, address poolManager, uint24 fee, bytes32 parameters) poolKey, int24 tickLower, int24 tickUpper, uint256 liquidity, uint128 amount0Max, uint128 amount1Max, address owner, bytes hookData",
  ]),
  [POOL_KEY, tickLower, tickUpper, liquidity, amount0Max, amount1Max, account.address, "0x"],
);
const close0 = encodeAbiParameters(parseAbiParameters("address"), [POOL_KEY.currency0]);
const close1 = encodeAbiParameters(parseAbiParameters("address"), [POOL_KEY.currency1]);
const payload = encodeAbiParameters(parseAbiParameters("bytes, bytes[]"), [
  ("0x" + CL_MINT_POSITION + CLOSE_CURRENCY + CLOSE_CURRENCY) as Hex,
  [mint, close0, close1],
]);

const hash = await walletClient.writeContract({
  address: CONTRACTS.clPositionManager,
  abi: positionManagerAbi,
  functionName: "modifyLiquidities",
  args: [payload, block.timestamp + 1_200n],
});
console.log("mint:", (await publicClient.waitForTransactionReceipt({ hash })).status);
Previous
Pools