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