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
| Contract | Address |
|---|---|
| Factory | 0xDE6763A041f8Fc94CA2EE5933736F78f6D1A11c5 |
| SwapRouter | 0x60F8A7642F0aeC06cE628224E743326B23Fe5208 |
| NonfungiblePositionManager | 0x1089f046B597f259BeFDC15Bf9C90E33616BA366 |
| Pool init code hash | 0xe34f199b19b2b4f47f68442619d555527d244f78a3297ea89325f843f87b8b54 |
Fee (uint24) | Swap fee | Tick spacing | Good for |
|---|---|---|---|
500 | 0.05% | 10 | Pairs that barely move against each other |
3000 | 0.3% | 60 | Most pairs |
10000 | 1% | 200 | Wild, volatile stuff |
The 100 tier isn't turned on, so getPool(..., 100) returns the zero address.
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
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:
- Ask the Swap API. It looks at V3 and V2 and tells you the best.
- Simulate the swap.
simulateContractrunsexactInputSinglewithout sending anything and returnsamountOut. Theaccountyou simulate from needs the balance (and allowance, for tokens), same as a real swap.
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
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:
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:
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
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
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.
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.
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.