Lock router
addLiquidityAndLock and mintAndLock, which add liquidity and lock it in the same transaction.
On this page
ArmoryLockRouter composes the V2 router, the position manager, and both
lockers so a token team can add liquidity and lock it atomically at launch.
There is no separate "lock it afterwards" window in which the LP can be pulled.
The router holds nothing between transactions. Every entry point pulls, spends,
refunds, and locks in one call, and every entry point is nonReentrant.
Its wiring is fixed at deploy and readable on chain:
IArmoryV2Router public immutable v2Router;
IArmoryV2Factory public immutable v2Factory; // = v2Router.factory()
INonfungiblePositionManager public immutable npm;
ArmoryTokenLocker public immutable tokenLocker;
ArmoryV3Locker public immutable v3Locker;
IDexFeeManager public immutable feeManager;
address public immutable wape; // = v2Router.WETH()v2Factory and wape are derived from the router in the constructor rather
than passed in, so they cannot disagree with it.
Approvals
Approve the lock router, not the underlying router or position manager. The
lock router pulls from msg.sender and re-approves downstream itself.
No approval to the token locker or the V3 locker is needed on these paths. The
router approves tokenLocker for the LP itself, and hands the position NFT to
the V3 locker with safeTransferFrom from its own custody.
V2: addLiquidityAndLock
function addLiquidityAndLock(
address tokenA,
address tokenB,
uint256 amountADesired,
uint256 amountBDesired,
uint256 amountAMin,
uint256 amountBMin,
uint64 unlockTime,
address unlocker,
uint256 deadline
) external returns (uint256 amountA, uint256 amountB, uint256 liquidity, uint256 lockId);Sequence:
safeTransferFromboth desired amounts from the caller into the router.forceApprovethe V2 router for both desired amounts.- Call
v2Router.addLiquiditywith the router itself as the recipient. The LP is minted to the router, never to the caller. - Refund the unused part of each side to
msg.sender. This is the ratio remainder: the pool's reserves decide which side is over-supplied. - Resolve the pair with
v2Factory.getPair(tokenA, tokenB),forceApprovethe token locker forliquidity, and calltokenLocker.createLockFor(msg.sender, pair, liquidity, unlockTime, unlocker). - Emit
V2LiquidityLocked(lockId, pair, msg.sender, liquidity, unlockTime).
The resulting lock has owner == msg.sender. unlocker passes through to the
locker's own defaulting rule, so a zero resolves to the lock owner, which is the
caller. unlockTime follows the locker's semantics: a future timestamp, or the
permanent sentinel.
Step 5 is a normal, permissionless createLockFor. Nothing about the resulting
lock is special. It is indistinguishable from one you would have made by hand,
and every locker function works on it.
The V2 path is not fee-on-transfer safe
addLiquidityAndLock transfers amountADesired in and later refunds
amountADesired - amountA. With a fee-on-transfer token the router received
less than amountADesired, so the refund is computed against a balance the
router does not have and the transfer reverts. The whole launch reverts with it.
ArmoryTokenLocker handles fee-on-transfer tokens correctly because it
measures the balance delta, but the lock router does not. For a token with a
transfer tax, add liquidity through the V2 router yourself and then call
createLock on the token locker as a second
transaction, accepting the window between the two.
Native APE variant
function addLiquidityAPEAndLock(
address token,
uint256 amountTokenDesired,
uint256 amountTokenMin,
uint256 amountAPEMin,
uint64 unlockTime,
address unlocker,
uint256 deadline
) external payable returns (uint256 amountToken, uint256 amountAPE, uint256 liquidity, uint256 lockId);msg.value is the desired APE amount and the underlying router wraps it. The
resulting lock is on the pair of token and WAPE. The token remainder is
refunded as above, then _refundNative() sends the router's entire APE balance
back to msg.sender:
function _refundNative() private {
uint256 balance = address(this).balance;
if (balance > 0) {
(bool success, ) = msg.sender.call{value: balance}("");
require(success, 'APE refund failed');
}
}The router accepts inbound APE only from the V2 router and the position manager,
since receive() reverts UnexpectedNative() for anyone else, so that balance
is always the caller's own change. A calling contract with no payable receive
function fails the refund and reverts the launch. Make sure yours accepts APE.
Worked example: launch a V2 pool with permanently locked LP
import { parseUnits } from "viem";
import { CONTRACTS, PERMANENT_UNLOCK_TIME, WAPE, ZERO_ADDRESS } from "./addresses";
import { ERC20_ABI, LOCK_ROUTER_ABI } from "./abis";
const token = "0x…"; // your token
const amountToken = parseUnits("1000000", 18);
const amountWape = parseUnits("50", 18);
const deadline = BigInt(Math.floor(Date.now() / 1000) + 900);
// 1. Approve the LOCK ROUTER for both sides.
for (const [asset, amount] of [
[token, amountToken],
[WAPE, amountWape],
] as const) {
await wallet.writeContract({
address: asset,
abi: ERC20_ABI,
functionName: "approve",
args: [CONTRACTS.lockRouter, amount],
});
}
// 2. Add and lock. Mins are zero only because this is the first liquidity in
// a brand new pair, so there is no ratio to slip against. On an existing
// pair, set them.
const hash = await wallet.writeContract({
address: CONTRACTS.lockRouter,
abi: LOCK_ROUTER_ABI,
functionName: "addLiquidityAndLock",
args: [
token,
WAPE,
amountToken,
amountWape,
0n, // amountAMin
0n, // amountBMin
PERMANENT_UNLOCK_TIME, // never unlocks
ZERO_ADDRESS, // unlocker defaults to the caller
deadline,
],
});The lockId is in the return data and in the V2LiquidityLocked and
LockCreated events. Read it from the receipt rather than from a subsequent
lockCount() call, because lockCount races with everybody else's locks.
V3: mintAndLock
struct MintAndLockParams {
address token0;
address token1;
uint24 fee;
int24 tickLower;
int24 tickUpper;
uint256 amount0Desired;
uint256 amount1Desired;
uint256 amount0Min;
uint256 amount1Min;
uint160 sqrtPriceX96; // 0 = pool must already exist; otherwise create/initialize here
address feeRecipient; // zero = caller
address feeModule; // zero = plain recipient transfers
bytes moduleData; // abi-encoded per the module's docs
bool feeConfigLocked; // one-way ossify, applied at creation
uint64 unlockTime;
uint256 deadline;
}
function mintAndLock(MintAndLockParams calldata params)
external payable
returns (uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1);Field order matters, because it is a struct and not named arguments. token0
must sort below token1, as everywhere else in V3.
Sequence:
- Pool creation, optional. If
sqrtPriceX96is non-zero, the router callsnpm.createAndInitializePoolIfNecessary(token0, token1, fee, sqrtPriceX96), readsslot0back on the returned pool and revertsPoolPriceMismatch()unless the price is exactly the one you asked for, then callsfeeManager.applyDefaultFeeProtocol(pool)on that pool. That second call activates the platform's configured default protocol fee on the new pool. It is documented as a no-op that never reverts when unconfigured, so it is safe to sit unconditionally in the launch path. Whether it does anything, and what value it writes, is read from chain: seeArmoryDexFeeManager.defaultFeeProtocol0(),defaultFeeProtocol1()andfeeProtocolOverridden(pool). Do not hardcode an expectation. - Native-side detection. Side 0 is native when
msg.valueis non-zero andtoken0is WAPE, and likewise for side 1. A native side is not pulled as an ERC-20. - Pull and approve. For each non-native side with a non-zero desired
amount:
safeTransferFromfrom the caller, thenforceApprovethe NFPM. - Mint. The router calls
npm.mintwithmsg.valueforwarded and itself as the recipient. The position is minted to the router. - Refund the ratio remainder on each non-native side.
- Refund native, if
msg.valueis non-zero:npm.refundETH()then_refundNative(). - Deposit into the locker. The router calls
npm.safeTransferFromfrom its own custody to the V3 locker, with the deposit payload encoded frommsg.sender, the resolved fee recipient,unlockTime,feeModule,feeConfigLockedandmoduleData. That triggersArmoryV3Locker.onERC721Received, which validates the unlock time, checks the module against the registry, and callsinitLock. - Emit
V3LiquidityLocked(tokenId, msg.sender, liquidity, unlockTime).
The lock owner is msg.sender. A feeRecipient of zero is resolved to
msg.sender by the router before encoding, so the locker never sees a zero
there on this path.
Send a non-zero sqrtPriceX96 only for a pool that does not exist
createAndInitializePoolIfNecessary does nothing to a pool that is already
initialized, so a non-zero start price is a request the chain can silently
ignore. Anyone watching the mempool can create your tier first, at a price of
their choosing. The router reads slot0 back and reverts
PoolPriceMismatch() on any difference, including one wei of sqrtPriceX96.
The consequence for callers: read v3Factory.getPool(token0, token1, fee) and
pass a start price only when it returns the zero address. For a pool that
exists, pass sqrtPriceX96: 0, which means "pool must already exist", and let
amount0Min and amount1Min bound the price you mint at. On a
PoolPriceMismatch() revert, re-read the pool, price your amounts against what
is actually there, and resend in that mode. Do not retry with the same start
price, and do not simply zero the field and resend the same amounts: your range
and your minimums were chosen for a price that is no longer the market.
Everything in step 7 can revert your launch
The locker's validation runs at the very end of the transaction, after the pool
exists in this call's state and the position has been minted. An unapproved
feeModule, an unlockTime at or below block.timestamp, or a module whose
initLock rejects your moduleData all revert the whole thing. Read
feeModuleRegistry.isApproved(module) and simulate the call before you send it.
Native APE on a V3 launch
mintAndLock is payable and forwards msg.value into npm.mint. To pay the
WAPE side natively, send a msg.value of at least that side's desired amount
and skip the ERC-20 approval for it. The position manager wraps, and
npm.refundETH() plus _refundNative() return the change. With a msg.value
of zero, WAPE is pulled like any other ERC-20.
The native-side detection keys only on whether token0 or token1 is WAPE
and whether msg.value is non-zero. If neither side is WAPE and you send value
anyway, nothing consumes it, npm.refundETH() returns it, and _refundNative()
sends it back.
Worked example: launch a V3 pool, permanently locked, fees split
import { encodeAbiParameters, parseUnits } from "viem";
import {
CONTRACTS,
DEAD_ADDRESS,
DEFAULT_FEE_TIER,
PERMANENT_UNLOCK_TIME,
TICK_SPACING_BY_FEE,
WAPE,
ZERO_ADDRESS,
} from "./addresses";
import {
ERC20_ABI,
LOCK_ROUTER_ABI,
V3_FACTORY_ABI,
} from "./abis";
const token = "0x…";
const [token0, token1] =
token.toLowerCase() < WAPE.toLowerCase() ? [token, WAPE] : [WAPE, token];
const fee = DEFAULT_FEE_TIER;
const spacing = TICK_SPACING_BY_FEE[fee];
// Full range, snapped to the tier's spacing.
const tickLower = Math.ceil(-887272 / spacing) * spacing;
const tickUpper = Math.floor(887272 / spacing) * spacing;
const amount0Desired = parseUnits("1000000", 18);
const amount1Desired = parseUnits("50", 18);
const deadline = BigInt(Math.floor(Date.now() / 1000) + 900);
// The starting price, Q64.96. Compute it from the ratio you intend to open at.
const sqrtPriceX96 = /* ... */ 0n;
// Fees split between the treasury and the burn address. Both legs must be
// non-zero and the legs must sum to the splitter's MAX_BPS(). Read that
// constant from the contract rather than assuming it.
const moduleData = encodeAbiParameters(
[{ type: "address[]" }, { type: "uint16[]" }],
[[treasury, DEAD_ADDRESS], [treasuryShare, burnShare]],
);
// A start price is only legal for a tier nobody has opened. If this returns an
// address, someone got there first: drop to `sqrtPriceX96: 0`, reprice the
// amounts against the pool that exists, and set real minimums.
const existing = await publicClient.readContract({
address: CONTRACTS.v3Factory,
abi: V3_FACTORY_ABI,
functionName: "getPool",
args: [token0, token1, fee],
});
if (existing !== ZERO_ADDRESS) throw new Error("pool already exists");
for (const [asset, amount] of [
[token0, amount0Desired],
[token1, amount1Desired],
] as const) {
await wallet.writeContract({
address: asset,
abi: ERC20_ABI,
functionName: "approve",
args: [CONTRACTS.lockRouter, amount],
});
}
const hash = await wallet.writeContract({
address: CONTRACTS.lockRouter,
abi: LOCK_ROUTER_ABI,
functionName: "mintAndLock",
args: [
{
token0,
token1,
fee,
tickLower,
tickUpper,
amount0Desired,
amount1Desired,
amount0Min: 0n,
amount1Min: 0n,
sqrtPriceX96, // non-zero: create and initialize the pool
feeRecipient: treasury, // ignored while feeModule is set
feeModule: CONTRACTS.feeSplitter,
moduleData,
feeConfigLocked: true, // irreversible: nobody can reroute these fees
unlockTime: PERMANENT_UNLOCK_TIME,
deadline,
},
],
});feeRecipient is still stored on the lock even when a module is set. It is the
fallback the lock reverts to if the module is ever cleared. On an ossified lock
it can never be reached, but set it to something sane anyway.
To pay the WAPE side natively, drop that side's approve and add a value
field to the writeContract call equal to that side's desired amount.
Dust, refunds, and leftovers
- V2 ratio remainder. Refunded to
msg.senderbefore the lock is created. - V3 ratio remainder. Refunded per non-native side after the mint.
- Native APE.
npm.refundETH()pulls the position manager's held change back to the router, then the router forwards its entire balance tomsg.sender. - Downstream allowances. The router
forceApproves the desired amount and the downstream contract spends the used amount, so a small residual allowance from the router to the V2 router or the NFPM can persist between calls. It is an allowance held by the router over its own empty balance, not by you. - The LP and the NFT. Neither ever touches the caller. Both go from the router straight to the locker.
Events and errors
event V2LiquidityLocked(uint256 indexed lockId, address indexed pair, address indexed owner, uint256 liquidity, uint64 unlockTime);
event V3LiquidityLocked(uint256 indexed tokenId, address indexed owner, uint128 liquidity, uint64 unlockTime);
error ZeroAddress(); // constructor only
error UnexpectedNative(); // receive() from anyone but v2Router / npm
error PoolPriceMismatch(); // mintAndLock: pool price is not the requested oneEverything else you will see comes from downstream: the V2 router's own
require strings, the position manager's, or the lockers' custom errors listed
on Token locker and
V3 position locker.