Integration recipes
Working examples covering a V2 quote-then-swap, a V3 multi-hop exact input, a Solidity contract calling a pool directly, off-chain address derivation, and reading a position's value.
On this page
Every function name and parameter name below is taken from the deployed sources. Addresses come from your own config; nothing here hardcodes one.
1. Quote then swap through the V2 router
getAmountsOut is a real view, so the quote is one eth_call.
import { createPublicClient, createWalletClient, http, custom, parseUnits } from "viem";
const publicClient = createPublicClient({ chain: apechain, transport: http() });
const walletClient = createWalletClient({ chain: apechain, transport: custom(window.ethereum) });
const amountIn = parseUnits("100", 18);
const path = [WAPE, TOKEN] as const; // WAPE-in leg
const slippageBps = 50n; // 0.5%
const deadline = BigInt(Math.floor(Date.now() / 1000) + 1200); // now + 20 min
// 1. quote
const amounts = await publicClient.readContract({
address: CONTRACTS.v2Router,
abi: v2RouterAbi,
functionName: "getAmountsOut",
args: [amountIn, path],
});
const quoted = amounts[amounts.length - 1];
const amountOutMin = (quoted * (10_000n - slippageBps)) / 10_000n;
// 2. swap. Native APE in, so no approval and no wrapping step:
// the router deposits into WAPE for you. path[0] MUST be WAPE.
const hash = await walletClient.writeContract({
address: CONTRACTS.v2Router,
abi: v2RouterAbi,
functionName: "swapExactETHForTokens",
args: [amountOutMin, path, recipient, deadline],
value: amountIn,
account,
});For an ERC-20 input, approve CONTRACTS.v2Router for path[0] first and call
swapExactTokensForTokens(amountIn, amountOutMin, path, to, deadline).
If the output token taxes transfers, the quote is wrong by construction. Switch
to swapExactETHForTokensSupportingFeeOnTransferTokens, which takes the same
arguments, returns nothing, and checks the recipient's measured balance delta
against amountOutMin.
2. Exact-input multi-hop through the V3 SwapRouter
import { encodePacked, parseUnits } from "viem";
// WAPE --0.3%--> USDC --2.5%--> TOKEN
// 20 + 3 + 20 + 3 + 20 = 66 bytes
const path = encodePacked(
["address", "uint24", "address", "uint24", "address"],
[WAPE, 3000, USDC, 25000, TOKEN],
);
const amountIn = parseUnits("250", 18);
const deadline = BigInt(Math.floor(Date.now() / 1000) + 1200);
// 1. quote. QuoterV2 is NOT a view — simulate it, never send it.
const { result } = await publicClient.simulateContract({
address: CONTRACTS.quoterV2,
abi: quoterV2Abi,
functionName: "quoteExactInput",
args: [path, amountIn],
});
const [amountOut, sqrtPriceX96AfterList, initializedTicksCrossedList, gasEstimate] = result;
const amountOutMinimum = (amountOut * 9950n) / 10_000n; // 0.5%
// 2. swap. WAPE is the input leg, so pay it natively with msg.value and let
// PeripheryPayments.pay() wrap for you. For an ERC-20 input, approve
// CONTRACTS.swapRouter and omit `value`.
const hash = await walletClient.writeContract({
address: CONTRACTS.swapRouter,
abi: swapRouterAbi,
functionName: "exactInput",
args: [{ path, recipient, deadline, amountIn, amountOutMinimum }],
value: amountIn,
account,
});To receive native APE instead of WAPE, set recipient to the zero address so
the output stays on the router, and batch unwrapWETH9 behind it:
import { encodeFunctionData } from "viem";
const swapCall = encodeFunctionData({
abi: swapRouterAbi,
functionName: "exactInput",
args: [{ path, recipient: ZERO_ADDRESS, deadline, amountIn, amountOutMinimum }],
});
const unwrapCall = encodeFunctionData({
abi: swapRouterAbi,
functionName: "unwrapWETH9",
args: [amountOutMinimum, recipient],
});
await walletClient.writeContract({
address: CONTRACTS.swapRouter,
abi: swapRouterAbi,
functionName: "multicall",
args: [[swapCall, unwrapCall]],
account,
});Re-quote immediately before signing
quoteExactInput prices the pool as it is right now. Between the quote and the
transaction landing, anyone can move the price. amountOutMinimum is your only
protection and it should come from a fresh simulation, not a cached one.
3. Solidity: call a V3 pool directly and implement the callback
Bypassing the router when you have your own accounting. The callback validation is the part you cannot skip.
// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity =0.7.6;
import '@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol';
import '@uniswap/v3-core/contracts/interfaces/callback/IUniswapV3SwapCallback.sol';
import '@uniswap/v3-core/contracts/libraries/TickMath.sol';
import '../libraries/PoolAddress.sol';
import '../libraries/CallbackValidation.sol';
import '../libraries/TransferHelper.sol';
contract DirectPoolSwapper is IUniswapV3SwapCallback {
address public immutable factory;
struct CallbackData {
address tokenIn;
address tokenOut;
uint24 fee;
address payer;
}
constructor(address _factory) {
factory = _factory;
}
/// @notice Exact-input swap straight against the pool.
function swapExactIn(
address tokenIn,
address tokenOut,
uint24 fee,
uint256 amountIn,
uint256 amountOutMinimum,
address recipient
) external returns (uint256 amountOut) {
bool zeroForOne = tokenIn < tokenOut;
IUniswapV3Pool pool = IUniswapV3Pool(
PoolAddress.computeAddress(factory, PoolAddress.getPoolKey(tokenIn, tokenOut, fee))
);
(int256 amount0, int256 amount1) = pool.swap(
recipient,
zeroForOne,
int256(amountIn), // positive == exact input
zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1,
abi.encode(CallbackData({
tokenIn: tokenIn,
tokenOut: tokenOut,
fee: fee,
payer: msg.sender
}))
);
// negative delta == the pool paid it out
amountOut = uint256(-(zeroForOne ? amount1 : amount0));
require(amountOut >= amountOutMinimum, 'Too little received');
}
/// @inheritdoc IUniswapV3SwapCallback
function uniswapV3SwapCallback(
int256 amount0Delta,
int256 amount1Delta,
bytes calldata data
) external override {
require(amount0Delta > 0 || amount1Delta > 0);
CallbackData memory decoded = abi.decode(data, (CallbackData));
// The only thing stopping an attacker from calling this directly.
// Recompute the pool address and require it to be msg.sender. Never
// read a pool address out of `data`.
CallbackValidation.verifyCallback(factory, decoded.tokenIn, decoded.tokenOut, decoded.fee);
uint256 amountToPay = amount0Delta > 0 ? uint256(amount0Delta) : uint256(amount1Delta);
TransferHelper.safeTransferFrom(decoded.tokenIn, decoded.payer, msg.sender, amountToPay);
}
}payer must have approved this contract for tokenIn. For a mint or a
flash the shape is identical. Implement
uniswapV3MintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes) or
uniswapV3FlashCallback(uint256 fee0, uint256 fee1, bytes), run the same
verifyCallback, and transfer to msg.sender before returning.
4. Derive pair and pool addresses off-chain
No RPC call. Both are plain CREATE2, and the difference between them is the salt encoding: V2 packs, V3 does not.
import { encodeAbiParameters, encodePacked, getCreate2Address, keccak256 } from "viem";
const V2_PAIR_INIT_CODE_HASH =
"0x21fb0317b1cb8af900b47ec04f932891e3d91dda70b5d0ddcd0f33aca7486b85";
const V3_POOL_INIT_CODE_HASH =
"0xe34f199b19b2b4f47f68442619d555527d244f78a3297ea89325f843f87b8b54";
function sortTokens(a: Address, b: Address): [Address, Address] {
return a.toLowerCase() < b.toLowerCase() ? [a, b] : [b, a];
}
/** ArmoryV2Factory pair address. Salt is abi.encodePacked(token0, token1). */
export function computePairAddress(factory: Address, tokenA: Address, tokenB: Address): Address {
const [token0, token1] = sortTokens(tokenA, tokenB);
return getCreate2Address({
from: factory,
salt: keccak256(encodePacked(["address", "address"], [token0, token1])),
bytecodeHash: V2_PAIR_INIT_CODE_HASH,
});
}
/** ArmoryV3Factory pool address. Salt is abi.encode(token0, token1, fee) — padded. */
export function computePoolAddress(
factory: Address,
tokenA: Address,
tokenB: Address,
fee: 1000 | 3000 | 10000 | 25000,
): Address {
const [token0, token1] = sortTokens(tokenA, tokenB);
return getCreate2Address({
from: factory,
salt: keccak256(
encodeAbiParameters(
[{ type: "address" }, { type: "address" }, { type: "uint24" }],
[token0, token1, fee],
),
),
bytecodeHash: V3_POOL_INIT_CODE_HASH,
});
}A derived address is not proof of existence
Both functions return an address for a pair or pool that was never deployed.
Check for code, or call v2Factory.getPair or v3Factory.getPool, before
treating the result as live. A V3 pool that exists but was never initialized has
code and still reverts LOK on every swap.
Where each hash comes from is set out on V2 core and V3 core.
5. Read a V3 position's value
Two halves: principal, which depends on the pool's current price, and uncollected fees, which depend on fee growth since the position last moved.
// 1. the position
const [
nonce, operator, token0, token1, fee,
tickLower, tickUpper, liquidity,
feeGrowthInside0LastX128, feeGrowthInside1LastX128,
tokensOwed0, tokensOwed1,
] = await publicClient.readContract({
address: CONTRACTS.nfpm,
abi: nfpmAbi,
functionName: "positions",
args: [tokenId],
});
// 2. the pool it lives in, derived off-chain — no factory round trip
const pool = computePoolAddress(CONTRACTS.v3Factory, token0, token1, fee);
const [sqrtPriceX96, tick] = await publicClient.readContract({
address: pool,
abi: v3PoolAbi,
functionName: "slot0",
});
const inRange = tick >= tickLower && tick < tickUpper;tokensOwed0 and tokensOwed1 are only the fees already accounted at the
position's last touch. Fees accrued since then are not in those numbers.
The honest way to read the full, current fee entitlement is to simulate the
collect the position manager would perform, with uint128 maximums:
const MAX_UINT128 = (1n << 128n) - 1n;
const { result: [fees0, fees1] } = await publicClient.simulateContract({
address: CONTRACTS.nfpm,
abi: nfpmAbi,
functionName: "collect",
args: [{ tokenId, recipient: owner, amount0Max: MAX_UINT128, amount1Max: MAX_UINT128 }],
account: owner, // collect is isAuthorizedForToken — simulate as the owner
});That works because collect calls pool.burn(tickLower, tickUpper, 0) first,
which forces the pool to bring feeGrowthInside up to date before the payout is
computed.
In Solidity, the equivalent is the PositionValue library, which does the same
arithmetic in a view:
import '../libraries/PositionValue.sol';
(uint256 amount0, uint256 amount1) = PositionValue.total(nfpm, tokenId, sqrtRatioX96);
(uint256 p0, uint256 p1) = PositionValue.principal(nfpm, tokenId, sqrtRatioX96);
(uint256 f0, uint256 f1) = PositionValue.fees(nfpm, tokenId);principal is LiquidityAmounts.getAmountsForLiquidity between the two tick
bounds at the supplied price. Pass the pool's live sqrtPriceX96, not a cached
one, or a range order will price as if it were still in range.