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
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:
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.