Documentation

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.

ContractAddressApeScan
ArmoryLockRouter0x44c0…8033
ArmoryTokenLocker0xD5d7…259F
ArmoryV3Locker0x98B8…d123
ArmoryV2Router0x98ca…5D7D
NonfungiblePositionManager0x7530…306a
ArmoryDexFeeManager0xb904…49Ad

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:

ArmoryLockRouter.sol
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.

FlowApprove
addLiquidityAndLocktokenA and tokenB, spender is the lock router, amount at least the desired amount
addLiquidityAPEAndLocktoken only. The APE side is msg.value
mintAndLocktoken0 and token1, spender is the lock router. Skip a side that is paid natively

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:

  1. safeTransferFrom both desired amounts from the caller into the router.
  2. forceApprove the V2 router for both desired amounts.
  3. Call v2Router.addLiquidity with the router itself as the recipient. The LP is minted to the router, never to the caller.
  4. 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.
  5. Resolve the pair with v2Factory.getPair(tokenA, tokenB), forceApprove the token locker for liquidity, and call tokenLocker.createLockFor(msg.sender, pair, liquidity, unlockTime, unlocker).
  6. 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

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

  1. Pool creation, optional. If sqrtPriceX96 is non-zero, the router calls npm.createAndInitializePoolIfNecessary(token0, token1, fee, sqrtPriceX96), reads slot0 back on the returned pool and reverts PoolPriceMismatch() unless the price is exactly the one you asked for, then calls feeManager.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: see ArmoryDexFeeManager.defaultFeeProtocol0(), defaultFeeProtocol1() and feeProtocolOverridden(pool). Do not hardcode an expectation.
  2. Native-side detection. Side 0 is native when msg.value is non-zero and token0 is WAPE, and likewise for side 1. A native side is not pulled as an ERC-20.
  3. Pull and approve. For each non-native side with a non-zero desired amount: safeTransferFrom from the caller, then forceApprove the NFPM.
  4. Mint. The router calls npm.mint with msg.value forwarded and itself as the recipient. The position is minted to the router.
  5. Refund the ratio remainder on each non-native side.
  6. Refund native, if msg.value is non-zero: npm.refundETH() then _refundNative().
  7. Deposit into the locker. The router calls npm.safeTransferFrom from its own custody to the V3 locker, with the deposit payload encoded from msg.sender, the resolved fee recipient, unlockTime, feeModule, feeConfigLocked and moduleData. That triggers ArmoryV3Locker.onERC721Received, which validates the unlock time, checks the module against the registry, and calls initLock.
  8. 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

launchV3.ts
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.sender before 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 to msg.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 one

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