Documentation

V3 periphery

The position manager, swap router, quoter and tick lens, covering the position lifecycle, the exact path byte layout, multicall and permit patterns, and why the quoter must be simulated.

On this page
ContractAddressApeScan
NonfungiblePositionManager0x7530…306a
SwapRouter0x4718…C46b
QuoterV20xbA1E…e7e0
TickLens0xb26B…85Ce
UniswapInterfaceMulticall0x0Cdb…d697

Uniswap V3 periphery 1.4.4, renamed only where it inherits from the renamed core. Every contract here is stock apart from the position NFT's name and symbol.

NonfungiblePositionManager

The NFT is Armory V3 Positions NFT-V1 / ARMORY-V3-POS. Token ids start at 1 and increment; id 0 is never minted.

function positions(uint256 tokenId) external 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
);

Reverts Invalid token ID for an unknown id. Note that token0, token1 and fee come from an internal pool-key cache keyed by a uint80 poolId, not from storage on the position itself. The position struct stores the pool id and positions() expands it for you.

Lifecycle

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 calldata params) external payable
    returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1);
 
struct IncreaseLiquidityParams {
    uint256 tokenId;
    uint256 amount0Desired; uint256 amount1Desired;
    uint256 amount0Min; uint256 amount1Min;
    uint256 deadline;
}
function increaseLiquidity(IncreaseLiquidityParams calldata params) external payable
    returns (uint128 liquidity, uint256 amount0, uint256 amount1);
 
struct DecreaseLiquidityParams {
    uint256 tokenId; uint128 liquidity;
    uint256 amount0Min; uint256 amount1Min;
    uint256 deadline;
}
function decreaseLiquidity(DecreaseLiquidityParams calldata params) external payable
    returns (uint256 amount0, uint256 amount1);
 
struct CollectParams {
    uint256 tokenId; address recipient;
    uint128 amount0Max; uint128 amount1Max;
}
function collect(CollectParams calldata params) external payable
    returns (uint256 amount0, uint256 amount1);
 
function burn(uint256 tokenId) external payable;

mint requires the pool to exist and be initialized; it has no create path. Use createAndInitializePoolIfNecessary first. token0 must sort before token1, and both ticks must be multiples of the pool's tickSpacing.

increaseLiquidity has no authorization check. Anyone can add liquidity to anyone's position, paying for it themselves. That is upstream behaviour and it is safe (the depositor gets nothing), but do not read it as an ownership signal.

decreaseLiquidity, collect and burn all carry isAuthorizedForToken, which reverts Not approved unless the caller is the owner, the approved operator, or an approved-for-all operator.

decreaseLiquidity moves value into tokensOwed; it never transfers. It reverts Price slippage check on the min amounts. collect is the only payout, it caps at tokensOwed, and recipient == address(0) means "leave it on the position manager", for a subsequent unwrapWETH9 or sweepToken in the same multicall.

burn requires liquidity == 0 && tokensOwed0 == 0 && tokensOwed1 == 0 and reverts Not cleared. The three-step exit is decreaseLiquidity, then collect, then burn, normally batched into one multicall.

Events: IncreaseLiquidity(uint256 indexed tokenId, uint128 liquidity, uint256 amount0, uint256 amount1) (also emitted by mint), DecreaseLiquidity(uint256 indexed tokenId, uint128 liquidity, uint256 amount0, uint256 amount1), Collect(uint256 indexed tokenId, address recipient, uint256 amount0, uint256 amount1).

Pool creation

function createAndInitializePoolIfNecessary(
    address token0, address token1, uint24 fee, uint160 sqrtPriceX96
) external payable returns (address pool);

Idempotent in both directions: it creates the pool if the factory has none, and initializes it if it exists but slot0.sqrtPriceX96 == 0. Requires token0 < token1.

Protocol fee initialization

A pool created this way starts with feeProtocol = 0. ArmoryDexFeeManager.applyDefaultFeeProtocol(pool) is permissionless and a no-op when the pool is already configured, so a pool-creation integration can call it immediately after initialization. See access control.

ERC721Permit

The position NFT is permit-enabled.

function permit(address spender, uint256 tokenId, uint256 deadline, uint8 v, bytes32 r, bytes32 s)
    external payable;
function DOMAIN_SEPARATOR() public view returns (bytes32);
bytes32 public constant PERMIT_TYPEHASH =
    0x49ecf333e5b8c95c40fdafc95c1ad136e8914a8fb55e9dc8bb01eaa83a2df9ad;
    // keccak256("Permit(address spender,uint256 tokenId,uint256 nonce,uint256 deadline)")

The EIP-712 domain uses name Armory V3 Positions NFT-V1, version "1", the chain id read at call time, and the position manager's address. The nonce is per-token (positions(tokenId).nonce), not per-owner, and it increments on use.

The position manager overrides _approve to store the operator inside the packed position struct, so getApproved(tokenId) reads _positions[tokenId].operator and reverts ERC721: approved query for nonexistent token for an unknown id.

Multicall and selfPermit

Both the position manager and the SwapRouter inherit Multicall and SelfPermit.

function multicall(bytes[] calldata data) external payable returns (bytes[] memory results);
 
function selfPermit(address token, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external payable;
function selfPermitIfNecessary(address token, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external payable;
function selfPermitAllowed(address token, uint256 nonce, uint256 expiry, uint8 v, bytes32 r, bytes32 s) external payable;
function selfPermitAllowedIfNecessary(address token, uint256 nonce, uint256 expiry, uint8 v, bytes32 r, bytes32 s) external payable;

multicall is delegatecall into address(this) in a loop, bubbling the first failure's revert string. The canonical compositions are selfPermit, then the real call, then refundETH; or decreaseLiquidity, collect, burn, in one transaction.

msg.value inside multicall

multicall is payable and each inner delegatecall sees the same msg.value for the whole batch. Batching two payable operations that each read msg.value will double-spend the accounting. Send native APE for one leg, and finish the batch with refundETH() so nothing is stranded.

selfPermitAllowed and its IfNecessary twin target the DAI-style permit(holder, spender, nonce, expiry, allowed, v, r, s) shape, not EIP-2612. The IfNecessary variants skip the permit when the allowance is already sufficient, which is what you want inside a multicall where a front-run permit would otherwise revert the whole batch.

Payments

From PeripheryPayments and PeripheryPaymentsWithFee, on both the position manager and the SwapRouter:

function unwrapWETH9(uint256 amountMinimum, address recipient) external payable;
function sweepToken(address token, uint256 amountMinimum, address recipient) external payable;
function refundETH() external payable;
function unwrapWETH9WithFee(uint256 amountMinimum, address recipient, uint256 feeBips, address feeRecipient) external payable;
function sweepTokenWithFee(address token, uint256 amountMinimum, address recipient, uint256 feeBips, address feeRecipient) external payable;

unwrapWETH9 unwraps the contract's entire WAPE balance and sends native APE to recipient, reverting Insufficient WETH9 below amountMinimum. sweepToken does the same for an ERC-20 (Insufficient token). refundETH returns the contract's whole native balance to msg.sender.

The WithFee variants require 0 < feeBips <= 100 and split off that share to feeRecipient before paying the recipient. They exist for integrator-built frontends; the canonical routers do not use them. The platform fee charged by the aggregator is a separate mechanism, read from chain, and is documented under aggregator.

receive() on both contracts reverts Not WETH9 for anyone but the WAPE contract. Native APE reaches these contracts only as msg.value on a payable call, or as an unwrap.

pay(), the internal payment primitive, wraps native APE automatically when the token is WAPE and the contract's native balance covers the amount. That is why you can pay a WAPE leg with msg.value and never touch the wrapper yourself.

SwapRouter

The deployed swap router is the concrete Uniswap V3 SwapRouter (the "SwapRouter01" generation), constructed with the V3 factory and WAPE. ISwapRouter.sol in interfaces/ is its interface, not a separate deployment. There is no SwapRouter02, no UniversalRouter and no Permit2 in this deployment.

struct ExactInputSingleParams {
    address tokenIn; address tokenOut; uint24 fee;
    address recipient; uint256 deadline;
    uint256 amountIn; uint256 amountOutMinimum;
    uint160 sqrtPriceLimitX96;
}
function exactInputSingle(ExactInputSingleParams calldata params) external payable returns (uint256 amountOut);
 
struct ExactInputParams {
    bytes path; address recipient; uint256 deadline;
    uint256 amountIn; uint256 amountOutMinimum;
}
function exactInput(ExactInputParams calldata params) external payable returns (uint256 amountOut);
 
struct ExactOutputSingleParams {
    address tokenIn; address tokenOut; uint24 fee;
    address recipient; uint256 deadline;
    uint256 amountOut; uint256 amountInMaximum;
    uint160 sqrtPriceLimitX96;
}
function exactOutputSingle(ExactOutputSingleParams calldata params) external payable returns (uint256 amountIn);
 
struct ExactOutputParams {
    bytes path; address recipient; uint256 deadline;
    uint256 amountOut; uint256 amountInMaximum;
}
function exactOutput(ExactOutputParams calldata params) external payable returns (uint256 amountIn);

Deadline enforcement is checkDeadline, which reverts Transaction too old. Slippage failures revert Too little received (exact input) and Too much requested (exact output).

recipient == address(0) is a sentinel meaning "keep the output on the router", for a following unwrapWETH9 or sweepToken in the same multicall.

sqrtPriceLimitX96 == 0 means "no limit", and the router substitutes TickMath.MIN_SQRT_RATIO + 1 or MAX_SQRT_RATIO - 1 depending on direction. Only the single-hop variants expose it; exactInput hardcodes 0 per hop.

exactOutput reverts rather than partially filling

An exact-output swap that cannot source the full output amount reverts and returns nothing, so a thin pool costs you gas and no fill. With sqrtPriceLimitX96 == 0 the full amount is required by a bare require, which means there is no revert string to read either.

Path encoding, the exact byte layout

An encoded path is tightly packed, with no length prefixes and no padding:

token0 (20 bytes) || fee (3 bytes, big-endian uint24) || token1 (20 bytes)
                  || fee (3) || token2 (20) || ...

The constants that define it:

ConstantValue
ADDR_SIZE20
FEE_SIZE3
NEXT_OFFSET = ADDR_SIZE + FEE_SIZE23
POP_OFFSET = NEXT_OFFSET + ADDR_SIZE43
MULTIPLE_POOLS_MIN_LENGTH = POP_OFFSET + NEXT_OFFSET66

So a single-hop path is exactly 43 bytes, and each extra hop adds 23. numPools is (path.length - 20) / 23. Any length that is not 20 + 23n decodes into garbage rather than reverting cleanly.

Exact-output paths are reversed. exactOutput and quoteExactOutput decode the first pool as (tokenOut, tokenIn, fee) and walk backwards from the output token to the input token. exactOutputSingle builds abi.encodePacked(params.tokenOut, params.fee, params.tokenIn) internally.

fee is the tier as a uint24 in hundredths of a bip: 1000, 3000, 10000 or 25000.

import { encodePacked } from "viem";
 
// exact input: WAPE -> USDC (0.3%) -> TOKEN (2.5%)
// 20 + 3 + 20 + 3 + 20 = 66 bytes
const path = encodePacked(
  ["address", "uint24", "address", "uint24", "address"],
  [wape, 3000, usdc, 25000, token],
);
 
// exact output: same route, encoded from the output token backwards
const outPath = encodePacked(
  ["address", "uint24", "address", "uint24", "address"],
  [token, 25000, usdc, 3000, wape],
);

The swap callback

uniswapV3SwapCallback(int256, int256, bytes) decodes SwapCallbackData { bytes path; address payer; }, calls CallbackValidation.verifyCallback(factory, tokenIn, tokenOut, fee) (which recomputes the pool address and requires msg.sender to equal it), and then either pays (exact input, or the last hop of an exact output) or recurses into the next hop (exact output multi-hop). The total input for exactOutput comes back through a transient amountInCached field.

QuoterV2

struct QuoteExactInputSingleParams {
    address tokenIn; address tokenOut;
    uint256 amountIn; uint24 fee; uint160 sqrtPriceLimitX96;
}
function quoteExactInputSingle(QuoteExactInputSingleParams memory params)
    public returns (uint256 amountOut, uint160 sqrtPriceX96After,
                    uint32 initializedTicksCrossed, uint256 gasEstimate);
 
function quoteExactInput(bytes memory path, uint256 amountIn)
    public returns (uint256 amountOut, uint160[] memory sqrtPriceX96AfterList,
                    uint32[] memory initializedTicksCrossedList, uint256 gasEstimate);
 
struct QuoteExactOutputSingleParams {
    address tokenIn; address tokenOut;
    uint256 amount; uint24 fee; uint160 sqrtPriceLimitX96;
}
function quoteExactOutputSingle(QuoteExactOutputSingleParams memory params)
    public returns (uint256 amountIn, uint160 sqrtPriceX96After,
                    uint32 initializedTicksCrossed, uint256 gasEstimate);
 
function quoteExactOutput(bytes memory path, uint256 amountOut)
    public returns (uint256 amountIn, uint160[] memory sqrtPriceX96AfterList,
                    uint32[] memory initializedTicksCrossedList, uint256 gasEstimate);

Never send a quoter call as a transaction

None of these are view. They execute a real pool.swap inside a try, and the quoter's own swap callback reverts on purpose with 96 bytes of ABI-encoded (uint256, uint160, int24), which the quoter catches and decodes. Call them with eth_call: simulateContract in viem, callStatic in ethers. Sending one as a transaction spends gas and returns nothing usable.

parseRevertReason re-throws any revert data that is not exactly 96 bytes, so a real revert from the pool comes back as its own string, and anything shorter than 68 bytes becomes Unexpected error.

Note the field-order trap. QuoteExactInputSingleParams orders (tokenIn, tokenOut, amountIn, fee, sqrtPriceLimitX96), so amountIn sits before fee, unlike ISwapRouter.ExactInputSingleParams where fee comes third. The two structs are not interchangeable.

initializedTicksCrossed is a useful liquidity-fragmentation signal, and gasEstimate is measured rather than modelled. The multi-hop variants return one entry per hop. The number a caller usually wants is the last hop's, because that is where the trade lands. Summing or averaging them is meaningless.

A quote taken in the same block against unchanged state matches the router's execution. Use QuoterV2; its address is listed on the addresses page.

TickLens

struct PopulatedTick { int24 tick; int128 liquidityNet; uint128 liquidityGross; }
 
function getPopulatedTicksInWord(address pool, int16 tickBitmapIndex)
    external view returns (PopulatedTick[] memory populatedTicks);

One call per 256-tick-index word of the pool's bitmap. It reads the word, counts set bits, then fetches ticks(tick) for each, so it is a genuine view and a plain eth_call. The tick for bit i of word w is ((w << 8) + i) * tickSpacing. Results come back descending.

Batch words with UniswapInterfaceMulticall rather than issuing one RPC per word. A wide range on the 2.5% tier (tickSpacing 500) still spans many words.

Libraries worth knowing

These are internal libraries. Inline them into your own contract; there is no deployed copy to call.

LibraryUse
PoolAddressgetPoolKey, computeAddress, POOL_INIT_CODE_HASH
CallbackValidationverifyCallback(factory, tokenA, tokenB, fee)
PathhasMultiplePools, numPools, decodeFirstPool, getFirstPool, skipToken
PositionKeycompute(owner, tickLower, tickUpper) for pool.positions
LiquidityAmountsgetLiquidityForAmounts, getAmountsForLiquidity and their single-sided forms
PositionValuetotal, principal, fees against a position manager token id
OracleLibrary, PoolTicksCounter, SqrtPriceMathPartialTWAP and quoting helpers