Documentation

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

ContractAssetLock key
ArmoryTokenLockerany ERC-20, including Armory V2 LP tokenslockId (uint256, 1-based)
ArmoryV3Lockerone NFPM ERC-721 positionNFPM tokenId
ArmoryLockRouternothing, evernone

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

ContractAddressApeScan
ArmoryTokenLocker0xD5d7…259F
ArmoryV3Locker0x98B8…d123
ArmoryLockRouter0x44c0…8033
ArmoryFeeModuleRegistry0xA9De…0A23
ArmoryFeeSplitter0x5616…1B4b
ArmoryDexFeeManager0xb904…49Ad

The two contracts the router composes are also part of the deploy list and are resolved the same way:

ContractAddressApeScan
ArmoryV2Router0x98ca…5D7D
ArmoryV2Factory0x7AA7…50D5
NonfungiblePositionManager0x7530…306a

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

So 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