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.
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 tokenIdRead 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.v3LockerThe 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.
// 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.senderof thecreateLockForcall, 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
unlockerresolves toowner, not to the caller. If you pass your own contract there, your contract becomes the only withdrawer and the user'sownerrights amount to extending the lock and nothing else. - The lock is not yours. An
ownerofusermeans you cannot extend, cannot withdraw, and cannot transfer it, becauseArmoryTokenLockerhas notransferLockOwnership. 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.