Documentation

Integration recipes

viem and Solidity snippets for the locker operations an integrator actually needs.

On this page

Every snippet uses real function and parameter names. CONTRACTS and the ABI constants represent your application's address and ABI modules.

ContractAddressApeScan
ArmoryTokenLocker0xD5d7…259F
ArmoryV3Locker0x98B8…d123
ArmoryLockRouter0x44c0…8033
ArmoryFeeModuleRegistry0xA9De…0A23
ArmoryFeeSplitter0x5616…1B4b
NonfungiblePositionManager0x7530…306a

Create an ERC-20 or LP lock

Two transactions: approve the token locker, then create.

import { parseUnits } from "viem";
import { CONTRACTS, ZERO_ADDRESS } from "./addresses";
import { ERC20_ABI, TOKEN_LOCKER_ABI } from "./abis";
 
const token = "0x…";
const amount = parseUnits("250000", 18);
const unlockTime = BigInt(Math.floor(Date.now() / 1000) + 365 * 24 * 3600);
 
await wallet.writeContract({
  address: token,
  abi: ERC20_ABI,
  functionName: "approve",
  args: [CONTRACTS.tokenLocker, amount],
});
 
const hash = await wallet.writeContract({
  address: CONTRACTS.tokenLocker,
  abi: TOKEN_LOCKER_ABI,
  functionName: "createLock",
  args: [
    token,
    amount,
    unlockTime,
    ZERO_ADDRESS, // unlocker defaults to the caller
  ],
});

The lockId is the return value and is also LockCreated.lockId in the receipt. If you pass a non-zero unlocker, remember three consequences: that address is the only one that can ever withdraw, the tokens go to it, and it cannot be changed for the life of the lock.

Read every lock for an owner

locksOf is append-only, so filter after reading.

import { CONTRACTS, isPermanentUnlock } from "./addresses";
import { TOKEN_LOCKER_ABI } from "./abis";
 
const ids = await client.readContract({
  address: CONTRACTS.tokenLocker,
  abi: TOKEN_LOCKER_ABI,
  functionName: "locksOf",
  args: [owner],
});
 
const records = await client.multicall({
  contracts: ids.map((lockId) => ({
    address: CONTRACTS.tokenLocker,
    abi: TOKEN_LOCKER_ABI,
    functionName: "getLock",
    args: [lockId],
  })),
  allowFailure: false,
});
 
const now = BigInt(Math.floor(Date.now() / 1000));
const rows = ids.map((lockId, i) => {
  const lock = records[i];
  const permanent = isPermanentUnlock(lock.unlockTime);
  return {
    lockId,
    token: lock.token,
    amount: lock.amount,
    owner: lock.owner,
    unlocker: lock.unlocker,
    permanent,
    // Withdrawn locks stay in locksOf forever.
    open: !lock.withdrawn,
    withdrawable: !lock.withdrawn && !permanent && lock.unlockTime <= now,
  };
});

Rendering locksOf without re-reading each record shows other people's positions

Both lockers' locksOf arrays are append-only. Withdrawn locks are never removed, and on the V3 locker transferLockOwnership appends the id to the new owner's array without deleting it from the old one, so the same tokenId is listed under two addresses at once. Any integrator enumerating locks has to handle duplicates and stale entries: read the record for every id, compare owner to the address you queried, and drop anything that no longer matches.

The V3 side is the same shape with CONTRACTS.v3Locker, V3_LOCKER_ABI, and getLock(tokenId). There, a withdrawn lock is deleted, so it comes back with a zero owner.

Extend or relock

// ERC-20 or LP lock. Owner only.
await wallet.writeContract({
  address: CONTRACTS.tokenLocker,
  abi: TOKEN_LOCKER_ABI,
  functionName: "extendLock",
  args: [lockId, newUnlockTime],
});
 
// V3 position lock. Owner only.
// Pass PERMANENT_UNLOCK_TIME to make it forever.
await wallet.writeContract({
  address: CONTRACTS.v3Locker,
  abi: V3_LOCKER_ABI,
  functionName: "extendLock",
  args: [tokenId, newUnlockTime],
});

newUnlockTime must be strictly greater than both the current unlockTime and block.timestamp. Compute the floor from the lock, not from now. A lock with two years left needs a date more than two years out, and "one more year" from a picker that measured from now is a reverted transaction:

export function unlockFloor(
  current: string | bigint | null | undefined,
  now: number,
): number {
  const base = Math.floor(now);
  if (current === null || current === undefined) return base;
  if (isPermanentUnlock(current)) return Number.MAX_SAFE_INTEGER;
  return Math.max(base, Number(current));
}

An expired lock that was never withdrawn floors at now, not at its lapsed date, and relocks fine.

Withdraw at expiry

// ERC-20 or LP: must be sent by lock.unlocker, and the tokens go to lock.unlocker.
await wallet.writeContract({
  address: CONTRACTS.tokenLocker,
  abi: TOKEN_LOCKER_ABI,
  functionName: "withdraw",
  args: [lockId],
});
 
// V3 position: must be sent by lock.owner, and the NFT goes to lock.owner.
await wallet.writeContract({
  address: CONTRACTS.v3Locker,
  abi: V3_LOCKER_ABI,
  functionName: "withdraw",
  args: [tokenId],
});

Before the V3 withdraw, crank collect(tokenId) if the lock has a fee recipient or module that should get the accrued fees. The NFT leaves with uncollected fees still attached, and they land with the owner instead. See V3 position locker for the exact ordering.

Claim a vesting tranche

const claimable = await client.readContract({
  address: CONTRACTS.tokenLocker,
  abi: TOKEN_LOCKER_ABI,
  functionName: "claimable",
  args: [vestingId],
});
 
if (claimable > 0n) {
  await wallet.writeContract({
    address: CONTRACTS.tokenLocker,
    abi: TOKEN_LOCKER_ABI,
    functionName: "claim",
    args: [vestingId],
  });
}

Beneficiary only, all or nothing, and a zero claim reverts NothingToClaim(). Use vestedAmount(vestingId, timestamp) to draw the curve at arbitrary points.

Lock an existing V3 position

First read tokensOwed0/1 off the position: the locker rejects deposits that carry pending amounts (PendingTokensOwed()), because a prior decreaseLiquidity parks principal there and the first fee crank would sweep it out through the fee route.

const position = await publicClient.readContract({
  address: CONTRACTS.nfpm,
  abi: NFPM_ABI,
  functionName: "positions",
  args: [tokenId],
});
const pendingOwed = position[10] > 0n || position[11] > 0n;

Nothing pending: two transactions, because the deposit route is an ERC-721 safeTransferFrom.

import { NFPM_ABI, V3_LOCKER_ABI } from "./abis";
 
await wallet.writeContract({
  address: CONTRACTS.nfpm,
  abi: NFPM_ABI,
  functionName: "approve",
  args: [CONTRACTS.v3Locker, tokenId],
});
 
await wallet.writeContract({
  address: CONTRACTS.v3Locker,
  abi: V3_LOCKER_ABI,
  functionName: "lock",
  args: [
    tokenId,
    feeRecipient,    // zero resolves to the caller
    unlockTime,
    feeModule,       // zero means plain transfers to feeRecipient
    feeConfigLocked, // true is irreversible
    moduleInit,      // "0x" when feeModule is zero
  ],
});

Something pending: use one NFPM multicall that pays the pending amounts to the user's wallet and deposits in the same transaction. No approval leg is needed because the user calls the NFPM directly:

import { encodeAbiParameters, encodeFunctionData } from "viem";
 
const maxUint128 = 2n ** 128n - 1n;
const lockData = encodeAbiParameters(
  [
    { type: "address" }, // owner
    { type: "address" }, // feeRecipient
    { type: "uint64" },  // unlockTime
    { type: "address" }, // feeModule
    { type: "bool" },    // feeConfigLocked
    { type: "bytes" },   // moduleInit
  ],
  [user, feeRecipient, unlockTime, feeModule, feeConfigLocked, moduleInit],
);
 
await wallet.writeContract({
  address: CONTRACTS.nfpm,
  abi: NFPM_ABI,
  functionName: "multicall",
  args: [[
    encodeFunctionData({
      abi: NFPM_ABI,
      functionName: "collect",
      // recipient is the USER, explicitly — address(0) tells the NFPM to
      // keep the funds for itself, not to pay the caller
      args: [{ tokenId, recipient: user, amount0Max: maxUint128, amount1Max: maxUint128 }],
    }),
    encodeFunctionData({
      abi: NFPM_ABI,
      // the 4-arg overload; a bare 3-arg transfer reverts on decode
      functionName: "safeTransferFrom",
      args: [user, CONTRACTS.v3Locker, tokenId, lockData],
    }),
  ]],
});

lock always makes msg.sender the lock owner. To lock a position on behalf of somebody else, call npm.safeTransferFrom(from, v3Locker, tokenId, data) yourself with a different owner in the encoded payload.

Crank fee collection

await wallet.writeContract({
  address: CONTRACTS.v3Locker,
  abi: V3_LOCKER_ABI,
  functionName: "collect",
  args: [tokenId],
});

No access control. Simulate first: a zero-fee collect succeeds and does nothing, but a lock whose module reverts takes your gas with it. For a keeper, batch with collectMany(uint256[]). Note that it reverts the entire batch on any unknown id, so validate every id against getLock before including it.

Configure or retune a fee split

import { encodeAbiParameters } from "viem";
import { FEE_SPLITTER_ABI, V3_LOCKER_ABI } from "./abis";
 
// Legs must be non-zero and sum exactly to the splitter's MAX_BPS().
const maxBps = await client.readContract({
  address: CONTRACTS.feeSplitter,
  abi: FEE_SPLITTER_ABI,
  functionName: "MAX_BPS",
});
 
const recipients = [treasury, staking];
const shares = [treasuryShare, stakingShare]; // must total maxBps
 
// Rotate an existing lock onto the splitter, initializing it in the same call.
await wallet.writeContract({
  address: CONTRACTS.v3Locker,
  abi: V3_LOCKER_ABI,
  functionName: "setFeeModule",
  args: [
    tokenId,
    CONTRACTS.feeSplitter,
    encodeAbiParameters(
      [{ type: "address[]" }, { type: "uint16[]" }],
      [recipients, shares],
    ),
  ],
});
 
// Later: retune without touching the locker.
await wallet.writeContract({
  address: CONTRACTS.feeSplitter,
  abi: FEE_SPLITTER_ABI,
  functionName: "setSplit",
  args: [tokenId, recipients, shares],
});

The registry read needs only one ABI entry:

const FEE_MODULE_REGISTRY_ABI = [
  {
    type: "function",
    name: "isApproved",
    stateMutability: "view",
    inputs: [{ name: "module", type: "address" }],
    outputs: [{ name: "", type: "bool" }],
  },
] as const;
 
const approved = await client.readContract({
  address: CONTRACTS.feeModuleRegistry,
  abi: FEE_MODULE_REGISTRY_ABI,
  functionName: "isApproved",
  args: [CONTRACTS.feeSplitter],
});

Read isApproved before you offer a module

Approval can change, so read the getter at render time and gate the picker on the answer rather than shipping either assumption. Fee modules covers the lifecycle and why un-approval is not a kill switch for locks that already selected a module.

Launch with locked liquidity, end to end

The whole flow for a V3 launch, as a sequence of signatures.

0. v3Factory.getPool(token0, token1, fee) must be the zero address
   (otherwise the tier is open already: sqrtPriceX96 must be 0 below)
1. approve token0 to the lock router
2. approve token1 to the lock router   (skip for a natively-paid WAPE side)
3. mintAndLock({ ..., sqrtPriceX96: <initial price>, unlockTime: PERMANENT })
 
After step 3, in a single transaction:
  - the pool exists and is initialized at your price
  - the platform's default protocol fee has been applied to it, if configured
  - the position is minted
  - the ratio remainder and any native change are back in your wallet
  - the NFT is inside ArmoryV3Locker, owned by you, permanently
  - your fee module, if any, has been initialized for this tokenId

Read tokenId from the V3LiquidityLocked event rather than guessing. Then verify what you built, from chain, before you tell anyone it is locked:

const lock = await client.readContract({
  address: CONTRACTS.v3Locker,
  abi: V3_LOCKER_ABI,
  functionName: "getLock",
  args: [tokenId],
});
 
// lock.owner           -> you
// lock.unlockTime      -> isPermanentUnlock() should be true
// lock.feeModule       -> your module, or zero
// lock.feeConfigLocked -> whether the routing is frozen
// nfpm.ownerOf(tokenId) -> CONTRACTS.v3Locker

The V2 equivalent is the same three steps with addLiquidityAndLock, and the verification is tokenLocker.getLock(lockId) plus pair.balanceOf(tokenLocker). The full parameter reference for both is on Lock router.

Solidity: lock on behalf of your users

createLockFor is permissionless, so a launchpad, vault, or presale contract can lock for a user without the user ever holding the LP.

LockOnBehalf.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
 
import {IERC20} from "@openzeppelin/contracts-v5/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts-v5/token/ERC20/utils/SafeERC20.sol";
import {ArmoryTokenLocker} from "../dex/lockers/ArmoryTokenLocker.sol";
 
contract LockOnBehalf {
    using SafeERC20 for IERC20;
 
    ArmoryTokenLocker public immutable tokenLocker;
 
    event LockedFor(address indexed user, uint256 indexed lockId, uint256 amount);
 
    constructor(address tokenLocker_) {
        tokenLocker = ArmoryTokenLocker(tokenLocker_);
    }
 
    /// Pulls `amount` from the caller and locks it so that `user` owns the
    /// lock and is the only address that can ever withdraw it.
    function lockFor(address user, address token, uint256 amount, uint64 unlockTime)
        external
        returns (uint256 lockId)
    {
        // createLockFor pulls from THIS contract, so the tokens must be here
        // and this contract must approve the locker.
        IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
        IERC20(token).forceApprove(address(tokenLocker), amount);
 
        // owner = user, unlocker = 0 resolves to the owner.
        // The locker measures what actually arrived, so a fee-on-transfer
        // token produces a lock smaller than `amount`. Read the return value
        // or the LockCreated event; do not assume.
        lockId = tokenLocker.createLockFor(user, token, amount, unlockTime, address(0));
 
        emit LockedFor(user, lockId, amount);
    }
}

Three things this gets right and a naive version does not:

  • The pull is from the msg.sender of the createLockFor call, which is this contract, not the end user. The tokens have to be in this contract and it has to hold the approval.
  • A zero unlocker resolves to owner, not to the caller. If you pass your own contract there, your contract becomes the only withdrawer and the user's owner rights amount to extending the lock and nothing else.
  • The lock is not yours. An owner of user means you cannot extend, cannot withdraw, and cannot transfer it, because ArmoryTokenLocker has no transferLockOwnership. Decide the owner and unlocker split before deployment, because it is permanent.

If you need the lock to be movable later, use the V3 locker, which has transferLockOwnership, or make owner a contract you control and let that contract mediate.