Skip to content
LitVMSwapDocs
Open app
Developers / V3 pools
Developers · On chain

V3 pools

Concentrated liquidity. LPs pick a price range, swaps get deeper liquidity near the current price, and every position is an NFT. The contracts use the standard Uniswap V3 interfaces.

Addresses and fee tiers

ContractAddress
Factory0xDE6763A041f8Fc94CA2EE5933736F78f6D1A11c5
SwapRouter0x60F8A7642F0aeC06cE628224E743326B23Fe5208
NonfungiblePositionManager0x1089f046B597f259BeFDC15Bf9C90E33616BA366
Pool init code hash0xe34f199b19b2b4f47f68442619d555527d244f78a3297ea89325f843f87b8b54
Fee (uint24)Swap feeTick spacingGood for
5000.05%10Pairs that barely move against each other
30000.3%60Most pairs
100001%200Wild, volatile stuff

The 100 tier isn't turned on, so getPool(..., 100) returns the zero address.

Which router?

This is the original SwapRouter, so every swap struct has a deadline field. If you're used to SwapRouter02, don't drop it or your calldata won't match.

Find a pool and read its price

TypeScript
import { createPublicClient, http, parseAbi } from 'viem'
import { litvm } from './litvm'

const client = createPublicClient({ chain: litvm, transport: http() })

const FACTORY = '0xDE6763A041f8Fc94CA2EE5933736F78f6D1A11c5'
const WZKLTC = '0x315374AA9b5536037Cc1Efeea2439CCC0913A77e'
const ZKUSDC = '0xdf69970B2fE416339187aA41D39882e864984CE9'

const factoryAbi = parseAbi(['function getPool(address tokenA, address tokenB, uint24 fee) view returns (address)'])
const poolAbi = parseAbi([
  'function token0() view returns (address)',
  'function token1() view returns (address)',
  'function liquidity() view returns (uint128)',
  'function slot0() view returns (uint160 sqrtPriceX96, int24 tick, uint16 observationIndex, uint16 observationCardinality, uint16 observationCardinalityNext, uint8 feeProtocol, bool unlocked)',
])

const pool = await client.readContract({
  address: FACTORY,
  abi: factoryAbi,
  functionName: 'getPool',
  args: [WZKLTC, ZKUSDC, 3000],
})

const [sqrtPriceX96, tick] = await client.readContract({ address: pool, abi: poolAbi, functionName: 'slot0' })

// price of token0 in units of token1 (adjust for decimals if they differ)
const price = (Number(sqrtPriceX96) / 2 ** 96) ** 2
console.log({ pool, tick, price })

Tokens are sorted inside a pool: token0 is the one with the smaller address. For wzkLTC/ZKUSDC, token0 is wzkLTC, so price is ZKUSDC per wzkLTC.

Getting a price

There's no Quoter contract on LitVM. You've got two easy options:

  1. Ask the Swap API. It looks at V3 and V2 and tells you the best.
  2. Simulate the swap. simulateContract runs exactInputSingle without sending anything and returns amountOut. The account you simulate from needs the balance (and allowance, for tokens), same as a real swap.
TypeScript
const { result: amountOut } = await client.simulateContract({
  account, // must hold the zkLTC
  address: SWAP_ROUTER,
  abi: swapRouterAbi,
  functionName: 'exactInputSingle',
  args: [{ ...params, amountOutMinimum: 0n }],
  value: params.amountIn,
})

Swap

TypeScript
import { parseAbi, parseEther } from 'viem'

const SWAP_ROUTER = '0x60F8A7642F0aeC06cE628224E743326B23Fe5208'

const swapRouterAbi = parseAbi([
  'struct ExactInputSingleParams { address tokenIn; address tokenOut; uint24 fee; address recipient; uint256 deadline; uint256 amountIn; uint256 amountOutMinimum; uint160 sqrtPriceLimitX96; }',
  'function exactInputSingle(ExactInputSingleParams params) payable returns (uint256 amountOut)',
  'function multicall(bytes[] data) payable returns (bytes[] results)',
  'function unwrapWETH9(uint256 amountMinimum, address recipient) payable',
  'function refundETH() payable',
])

const params = {
  tokenIn: WZKLTC, // pay with native zkLTC: use wzkLTC here and send value
  tokenOut: ZKUSDC,
  fee: 3000,
  recipient: account,
  deadline: BigInt(Math.floor(Date.now() / 1000) + 600),
  amountIn: parseEther('0.01'),
  amountOutMinimum: minOut, // from your quote, minus slippage
  sqrtPriceLimitX96: 0n,
} as const

await wallet.writeContract({
  account,
  address: SWAP_ROUTER,
  abi: swapRouterAbi,
  functionName: 'exactInputSingle',
  args: [params],
  value: params.amountIn, // the router wraps it for you
})

Getting native zkLTC out

Send the output to the router, then unwrap it to the user in the same transaction:

TypeScript
import { encodeFunctionData } from 'viem'

const swapCall = encodeFunctionData({
  abi: swapRouterAbi,
  functionName: 'exactInputSingle',
  args: [{ ...params, tokenIn: ZKUSDC, tokenOut: WZKLTC, recipient: SWAP_ROUTER }],
})
const unwrapCall = encodeFunctionData({
  abi: swapRouterAbi,
  functionName: 'unwrapWETH9',
  args: [minOut, account],
})

await wallet.writeContract({
  account,
  address: SWAP_ROUTER,
  abi: swapRouterAbi,
  functionName: 'multicall',
  args: [[swapCall, unwrapCall]],
})

(Approve the SwapRouter for ZKUSDC first.)

Positions

Every V3 position is an NFT minted by the NonfungiblePositionManager.

Pick ticks

Ticks have to be multiples of the pool's tick spacing. A simple "around the current price" range:

TypeScript
const spacing = 60 // fee 3000
const around = (tick: number, widthInSpacings: number) => {
  const base = Math.floor(tick / spacing) * spacing
  return { tickLower: base - widthInSpacings * spacing, tickUpper: base + widthInSpacings * spacing }
}

// full range for spacing 60
const FULL = { tickLower: -887220, tickUpper: 887220 }

Mint

TypeScript
const PM = '0x1089f046B597f259BeFDC15Bf9C90E33616BA366'

const pmAbi = parseAbi([
  'struct MintParams { address token0; address token1; uint24 fee; int24 tickLower; int24 tickUpper; uint256 amount0Desired; uint256 amount1Desired; uint256 amount0Min; uint256 amount1Min; address recipient; uint256 deadline; }',
  'function mint(MintParams params) payable returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1)',
  'function positions(uint256 tokenId) view returns (uint96 nonce, address operator, address token0, address token1, uint24 fee, int24 tickLower, int24 tickUpper, uint128 liquidity, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, uint128 tokensOwed0, uint128 tokensOwed1)',
  'function balanceOf(address owner) view returns (uint256)',
  'function tokenOfOwnerByIndex(address owner, uint256 index) view returns (uint256)',
  'struct CollectParams { uint256 tokenId; address recipient; uint128 amount0Max; uint128 amount1Max; }',
  'function collect(CollectParams params) payable returns (uint256 amount0, uint256 amount1)',
])

// approve PM for both tokens first; token0 must be the smaller address
await wallet.writeContract({
  account,
  address: PM,
  abi: pmAbi,
  functionName: 'mint',
  args: [
    {
      token0: WZKLTC,
      token1: ZKUSDC,
      fee: 3000,
      ...around(tick, 10),
      amount0Desired: parseEther('0.1'),
      amount1Desired: parseUnits('25', 18),
      amount0Min: 0n, // set real minimums in production
      amount1Min: 0n,
      recipient: account,
      deadline: BigInt(Math.floor(Date.now() / 1000) + 600),
    },
  ],
})

List a wallet's positions

TypeScript
const count = await client.readContract({ address: PM, abi: pmAbi, functionName: 'balanceOf', args: [owner] })

for (let i = 0n; i < count; i++) {
  const id = await client.readContract({ address: PM, abi: pmAbi, functionName: 'tokenOfOwnerByIndex', args: [owner, i] })
  const p = await client.readContract({ address: PM, abi: pmAbi, functionName: 'positions', args: [id] })
  console.log(id, p[2], p[3], p[4], 'ticks', p[5], p[6], 'liquidity', p[7])
}

Unclaimed fees

The usual trick: simulate collect from the owner. Nothing is sent, you just read what it would pay.

TypeScript
const MAX = 2n ** 128n - 1n
const { result: [fees0, fees1] } = await client.simulateContract({
  account: owner,
  address: PM,
  abi: pmAbi,
  functionName: 'collect',
  args: [{ tokenId: id, recipient: owner, amount0Max: MAX, amount1Max: MAX }],
})

Send the same call for real to claim them.

Out of range = not earning

A V3 position only earns swap fees while the price is inside its range. Outside, it's sitting entirely in one of the two tokens. Worth showing in your UI.