Documentation

V3 core

The V3 factory, pool deployer and pool, covering the fee tier set, the CREATE2 scheme and pool init code hash, the pool actions, the callbacks you must implement, and the oracle.

On this page
ContractAddressApeScan
ArmoryV3Factory0xAb52…434F

The V3 core is Uniswap V3 core v1.0.0, renamed, with exactly one functional change: the fee tier set baked into the factory constructor. The pool contract compiles to bytecode byte-identical to Uniswap's mainnet pool, which is why the pool init code hash below is Uniswap's canonical value unchanged.

ArmoryV3Factory

address public owner;
mapping(uint24 => int24) public feeAmountTickSpacing;
mapping(address => mapping(address => mapping(uint24 => address))) public getPool;
 
function createPool(address tokenA, address tokenB, uint24 fee)
    external returns (address pool);
function setOwner(address _owner) external;
function enableFeeAmount(uint24 fee, int24 tickSpacing) external;

Events are the upstream OwnerChanged(address,address), FeeAmountEnabled(uint24,int24) and PoolCreated(address indexed token0, address indexed token1, uint24 indexed fee, int24 tickSpacing, address pool).

createPool carries noDelegateCall, sorts the tokens, requires a non-zero feeAmountTickSpacing[fee], requires the pool not to exist, deploys, and populates getPool in both directions. Every guard is a bare require with no message, so a failed createPool gives you no revert string. The likely cause is a fee tier that is not enabled.

createPool does not initialize

A freshly created pool has slot0.sqrtPriceX96 == 0 and is unusable until someone calls pool.initialize(sqrtPriceX96). Every pool method under the lock modifier reverts LOK before initialization, because slot0.unlocked is still false. Use the periphery's createAndInitializePoolIfNecessary unless you have a reason not to.

Fee tiers

Four tiers are enabled in the constructor. This is the one deliberate divergence from upstream.

fee (uint24)RateTick spacingIntent
10000.1%20the tightest tier shipped
30000.3%60standard
100001%200volatile pairs
250002.5%500the launch tier, and not a Uniswap tier

Uniswap's 500 (0.05%) and 100 (0.01%) tiers are not enabled. This is a product decision: the venue is built around token launches rather than stablecoin and major pairs, so the spacing budget went to a 2.5% tier instead of a 0.05% one.

fee is in hundredths of a basis point, so 25000 is 2.5% and the same value is what goes into an encoded swap path. The byte layout is on the V3 periphery page.

Additional tiers can be enabled later, by the factory owner only. Do not assume the set is closed; read feeAmountTickSpacing(fee) if you need certainty. enableFeeAmount requires fee < 1000000, 0 < tickSpacing < 16384, and that the tier is not already set. A tier, once enabled, can never be removed.

PoolDeployer and the CREATE2 scheme

V3 pools are deployed with new ArmoryV3Pool{salt: ...}(), a CREATE2 with an empty constructor argument list. The pool reads its own configuration back out of the deployer:

struct Parameters {
    address factory;
    address token0;
    address token1;
    uint24 fee;
    int24 tickSpacing;
}
Parameters public parameters;
 
function deploy(address factory, address token0, address token1, uint24 fee, int24 tickSpacing)
    internal returns (address pool)
{
    parameters = Parameters({...});
    pool = address(new ArmoryV3Pool{salt: keccak256(abi.encode(token0, token1, fee))}());
    delete parameters;
}

That set-then-delete dance is the whole point: no constructor arguments means the creation code is identical for every pool, which means one init code hash covers all of them.

The salt is keccak256(abi.encode(token0, token1, fee)), using abi.encode and not abi.encodePacked, so it is three 32-byte words, unlike V2's packed salt.

Pool init code hash

0xe34f199b19b2b4f47f68442619d555527d244f78a3297ea89325f843f87b8b54

This is Uniswap V3's canonical mainnet pool init code hash.

pool = address(uint256(keccak256(abi.encodePacked(
    hex'ff',
    factory,
    keccak256(abi.encode(key.token0, key.token1, key.fee)),
    POOL_INIT_CODE_HASH
))));

PoolAddress.getPoolKey(tokenA, tokenB, fee) sorts for you. PoolAddress.computeAddress(factory, key) requires key.token0 < key.token1.

ArmoryV3Pool

Immutables and state

address  public immutable factory;
address  public immutable token0;
address  public immutable token1;
uint24   public immutable fee;
int24    public immutable tickSpacing;
uint128  public immutable maxLiquidityPerTick;
 
struct Slot0 {
    uint160 sqrtPriceX96;
    int24   tick;
    uint16  observationIndex;
    uint16  observationCardinality;
    uint16  observationCardinalityNext;
    uint8   feeProtocol;
    bool    unlocked;
}
Slot0 public slot0;
 
uint256 public feeGrowthGlobal0X128;
uint256 public feeGrowthGlobal1X128;
struct ProtocolFees { uint128 token0; uint128 token1; }
ProtocolFees public protocolFees;
uint128 public liquidity;
 
mapping(int24  => Tick.Info)     public ticks;
mapping(int16  => uint256)       public tickBitmap;
mapping(bytes32 => Position.Info) public positions;
Oracle.Observation[65535]        public observations;

slot0() is one SLOAD and returns all seven fields as a tuple. feeProtocol packs both directions into a single byte: feeProtocol0 = feeProtocol % 16 and feeProtocol1 = feeProtocol >> 4. Each nibble is either 0 (off) or a value in [4, 10], meaning "1/N of the LP fee on that side is diverted to the protocol accumulator instead of to LPs". That range is enforced by setFeeProtocol. Read it live if your accounting depends on it; it is per-pool mutable state, not a constant.

positions is keyed by keccak256(abi.encodePacked(owner, tickLower, tickUpper)), which the periphery exposes as PositionKey.compute. For any position minted through the position manager, owner is the position manager's address, not the NFT holder's.

Actions

function initialize(uint160 sqrtPriceX96) external;
 
function mint(address recipient, int24 tickLower, int24 tickUpper, uint128 amount, bytes calldata data)
    external returns (uint256 amount0, uint256 amount1);
 
function collect(address recipient, int24 tickLower, int24 tickUpper,
                 uint128 amount0Requested, uint128 amount1Requested)
    external returns (uint128 amount0, uint128 amount1);
 
function burn(int24 tickLower, int24 tickUpper, uint128 amount)
    external returns (uint256 amount0, uint256 amount1);
 
function swap(address recipient, bool zeroForOne, int256 amountSpecified,
              uint160 sqrtPriceLimitX96, bytes calldata data)
    external returns (int256 amount0, int256 amount1);
 
function flash(address recipient, uint256 amount0, uint256 amount1, bytes calldata data) external;

initialize reverts AI if already initialized. It seeds the oracle, sets unlocked = true and feeProtocol = 0, and emits Initialize(sqrtPriceX96, tick).

mint credits liquidity first, then calls uniswapV3MintCallback on msg.sender, then checks balances, reverting M0 or M1 if the tokens did not arrive.

burn does not transfer anything. It reduces liquidity and moves the owed amounts into the position's tokensOwed0 and tokensOwed1. collect is what pays out, and the position owner is always msg.sender. Calling burn(tickLower, tickUpper, 0) is the canonical way to force a fee-growth update without changing liquidity.

swap takes a signed amountSpecified: positive for exact input, negative for exact output. It returns signed deltas, where positive means the pool received that token and negative means it paid it out. Reverts:

ConditionRevert string
amountSpecified == 0AS
pool locked or uninitializedLOK
sqrtPriceLimitX96 not strictly between the current price and the corresponding TickMath bound in the swap directionSPL

flash charges fee (the pool's tier, in hundredths of a bip) rounded up on each side, calls uniswapV3FlashCallback(fee0, fee1, data), and reverts F0 or F1 if the balance did not come back with the fee. It requires non-zero in-range liquidity, reverting L otherwise. The flash fee splits with the protocol nibble the same way swap fees do.

Callbacks you must implement

Calling the pool directly means implementing the matching callback, because the pool transfers optimistically and verifies by balance afterwards.

function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata data) external;
function uniswapV3MintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes calldata data) external;
function uniswapV3FlashCallback(uint256 fee0, uint256 fee1, bytes calldata data) external;

Validate the caller in every callback

The pool calls back into msg.sender. Nothing stops an arbitrary contract from calling your callback directly with fabricated deltas and draining whatever the callback is willing to pay. The periphery's CallbackValidation.verifyCallback is the pattern: recompute the pool address from the factory, the token pair and the fee with PoolAddress.computeAddress, and require(msg.sender == pool). Never trust a pool address that arrived in data.

In the swap callback exactly one of amount0Delta and amount1Delta is positive for a normal swap. The positive one is what you owe, in that token, transferred to msg.sender before you return. A full worked implementation is in integration recipes.

The oracle

function observe(uint32[] calldata secondsAgos)
    external view
    returns (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s);
 
function snapshotCumulativesInside(int24 tickLower, int24 tickUpper)
    external view
    returns (int56 tickCumulativeInside, uint160 secondsPerLiquidityInsideX128, uint32 secondsInside);
 
function increaseObservationCardinalityNext(uint16 observationCardinalityNext) external;

A newly initialized pool has cardinality 1, a single slot, so a TWAP over any meaningful window is not available yet. Anyone can pay to grow the ring buffer with increaseObservationCardinalityNext, and the growth takes effect as swaps write into the new slots. If you depend on a V3 TWAP from a fresh pool, grow the cardinality yourself and wait for it to fill. The emitted event is IncreaseObservationCardinalityNext(uint16,uint16).

NoDelegateCall

ArmoryV3Pool and ArmoryV3Factory both inherit NoDelegateCall. It records address(this) as an immutable at construction and reverts (bare, no message) when a modified method runs under a different address(this).

Modified: snapshotCumulativesInside, observe, increaseObservationCardinalityNext, _modifyPosition (so mint and burn inherit it indirectly), swap, flash, and the factory's createPool.

Not modified: initialize, collect, setFeeProtocol, collectProtocol, and every view getter.

Practically: you cannot delegatecall into pool logic to run a swap in your own storage context. Compose by calling the pool, not by borrowing its code.

Owner-gated pool functions

function setFeeProtocol(uint8 feeProtocol0, uint8 feeProtocol1) external;
function collectProtocol(address recipient, uint128 amount0Requested, uint128 amount1Requested)
    external returns (uint128 amount0, uint128 amount1);

Both carry onlyFactoryOwner, which reads IUniswapV3Factory(factory).owner() live on every call. Neither touches LP funds: collectProtocol can only move the protocolFees accumulator, and it leaves one wei behind on each side so the storage slot is never cleared. Who holds the factory owner role is on the access control page.