Lockers overview
Three contracts that hold assets until a deadline nobody can move backwards, plus a router that adds liquidity and locks it in the same transaction.
On this page
Armory ships three locker contracts and one router over them. They share a vocabulary (lock record, owner, unlock time, the permanent sentinel) but they do not share a base class and they do not agree on access control. Read the differences before you wire a UI to either locker.
What each contract holds
ArmoryTokenLocker also carries a second, unrelated product in the same
contract: batch vesting schedules. Those are keyed by vestingId and share no
storage with locks.
ArmoryLockRouter is a composition layer. It pulls tokens, calls the V2 router
or the position manager, refunds the remainder, and hands the resulting LP or
NFT to the locker with the caller as the lock owner. It never holds a balance
between transactions.
Addresses
The two contracts the router composes are also part of the deploy list and are resolved the same way:
Shared concepts
The lock record
Both lockers store a struct in a public mapping and expose a getLock view that
returns the whole struct in one call. Both keep an append-only per-owner index,
locksOf(address), which is the enumeration surface for an indexer or a "my
locks" screen.
Enumerating locks returns stale entries and duplicates
A screen that renders locksOf without re-reading each record will show
withdrawn locks and, on the V3 locker, positions that belong to somebody else.
locksOf is append-only in the strict sense: withdrawn locks stay in it, and a
lock moved by transferLockOwnership is added to the new owner's array without
being removed from the old one. The same tokenId therefore appears in two
arrays at once. Read the lock record for every id and compare owner to the
address you queried before you show it as theirs.
Owner and unlock time
Every lock has an owner. On both lockers the owner is the only address that
can push the unlock time further out. Neither locker can shorten a lock:
extendLock requires newUnlockTime to be strictly greater than both the
current unlockTime and block.timestamp, so an expired-but-unwithdrawn lock
can be re-locked by supplying any future timestamp.
Creation is stricter than extension in one direction only. unlockTime must be
strictly greater than block.timestamp at creation, on both lockers.
The permanent sentinel
unlockTime == type(uint64).max means the lock never opens. The V3 locker names
it as a public constant:
uint64 public constant PERMANENT = type(uint64).max;ArmoryTokenLocker has no such constant. It gets the same behaviour
structurally, because block.timestamp < lock.unlockTime can never be false for
type(uint64).max, so withdraw reverts with StillLocked() forever. The V3
locker instead reverts with a dedicated PermanentLock() and refuses
extendLock with InvalidUnlockTime(), since nothing is greater than the
sentinel.
A TypeScript client can represent the sentinel as:
export const PERMANENT_UNLOCK_TIME = 18446744073709551615n;
export function isPermanentUnlock(unlockTime: bigint | string): boolean {
return BigInt(unlockTime) >= PERMANENT_UNLOCK_TIME;
}Note the >=. Treat any value at or above the sentinel as permanent rather than
testing equality.
Never format the sentinel as a date. Interpreted as a Unix timestamp it lands somewhere around the year 584 billion, which is not information. The UI says "permanent" in words instead, and so should yours. A date field on a permanent lock is a bug report waiting to happen.
Permanent means permanent
A permanent lock has no unlock path on either contract. There is no admin, no
sweep, no upgrade proxy, and no owner override. On the V3 locker the NFT's
liquidity cannot be decreased either, because the locker exposes no
decreaseLiquidity and no way to approve a third party over the held NFT.
Transfer of lock ownership
Only the V3 locker has it:
function transferLockOwnership(uint256 tokenId, address newOwner) external;ArmoryTokenLocker has no equivalent. Its owner and unlocker fields are
immutable for the life of the lock. If a token team needs the withdrawal right
to be movable, put a contract at unlocker at creation time. You cannot change
it later.
Withdraw semantics diverge
This is the trap. The two lockers gate withdrawal on different fields.
// ArmoryTokenLocker.withdraw
if (lock.unlocker != msg.sender) revert NotUnlocker();
// ... tokens are sent to lock.unlocker, not to lock.owner// ArmoryV3Locker.withdraw
if (positionLock.owner != msg.sender) revert NotLockOwner();
// ... the NFT goes to positionLock.ownerSo the owner of an ERC-20 lock cannot withdraw it unless they are also the unlocker, while the owner of a position lock controls withdrawal. Gate each UI action against the field used by that locker.
Fee routing is a V3-only concept
Locked ERC-20 and V2 LP earn nothing while locked, so ArmoryTokenLocker has no
fee surface at all. A locked V3 position keeps accruing swap fees, so
ArmoryV3Locker carries the fee-recipient and fee-module machinery plus the
one-way ossification switch.
Where to go next
- Token locker: ERC-20 and V2 LP locks, and the vesting product in the same contract.
- V3 position locker: escrow, fee collection, and ossification.
- Fee modules: the extension point, the approval registry, and a worked reference module.
- Lock router: add liquidity and lock it in one transaction.
- Integration recipes: the calls, in viem and in Solidity.