Documentation

V3 position locker

Escrowing an NFPM position so its liquidity can never leave while its swap fees keep flowing.

On this page

ArmoryV3Locker escrows a Nonfungible Position Manager ERC-721. While a position is held, the NFT can never leave and its liquidity can never be decreased: the contract exposes no transfer, no approval, no decreaseLiquidity, no burn, no sweep, and no upgrade path over held NFTs. There is no contract-level admin over locks or principal.

ContractAddressApeScan
ArmoryV3Locker0x98B8…d123
ArmoryFeeModuleRegistry0xA9De…0A23
NonfungiblePositionManager0x7530…306a

Two immutables are set at construction and cannot change:

ArmoryV3Locker.sol
INonfungiblePositionManager public immutable npm;
IFeeModuleRegistry public immutable moduleRegistry;

Both are checked non-zero in the constructor (ZeroAddress()).

The lock record

ArmoryV3Locker.sol
struct PositionLock {
    address owner;           // may extend / rotate fee config / transfer the lock
    address feeRecipient;    // where fees go when no module is set
    address feeModule;       // approved policy contract; zero = plain transfer
    address token0;
    address token1;
    uint64  unlockTime;      // PERMANENT = locked forever
    bool    feeConfigLocked; // one-way: recipient + module frozen forever
}
 
mapping(uint256 tokenId => PositionLock) public positionLocks;

token0 and token1 are cached from npm.positions(tokenId) at deposit so that collect never has to re-read them.

A lock exists if and only if owner != address(0). withdraw does delete positionLocks[tokenId], which is how a withdrawn lock becomes indistinguishable from one that never existed.

Depositing

The only route in is onERC721Received, and it only accepts calls from the configured NFPM:

function onERC721Received(address, address, uint256 tokenId, bytes calldata data)
    external returns (bytes4);
if (msg.sender != address(npm)) revert OnlyPositionManager();

The data argument is:

abi.encode(
    address owner,
    address feeRecipient,
    uint64  unlockTime,
    address feeModule,
    bool    feeConfigLocked,
    bytes   moduleInitData
)

Decoded and validated in this order:

  1. owner == address(0) reverts ZeroAddress().
  2. unlockTime <= block.timestamp reverts InvalidUnlockTime(). The PERMANENT sentinel passes.
  3. feeRecipient == address(0) defaults to owner.
  4. A non-zero feeModule must satisfy moduleRegistry.isApproved(feeModule) or the deposit reverts ModuleNotApproved().
  5. token0, token1, tokensOwed0 and tokensOwed1 are read from npm.positions(tokenId). Nonzero tokensOwed0 or tokensOwed1 reverts PendingTokensOwed(). See the callout below.
  6. The record is written and tokenId is appended to _locksByOwner[owner].
  7. If a module was set, IArmoryFeeModule(feeModule).initLock(tokenId, owner, moduleInit) is called inside the deposit. A module that reverts on init fails the whole deposit.
  8. PositionLocked is emitted and the ERC-721 receiver selector is returned.

Positions with pending tokensOwed are rejected

tokensOwed0/1 on the NFPM is not just fees: a prior decreaseLiquidity parks principal there until it is collected. If the locker accepted such a position, the first permissionless collect() crank would sweep that principal out through the fee route (to the recipient or module, not back to the depositor). So the deposit reverts PendingTokensOwed() instead.

To lock a position in that state without a separate transaction, batch the collect and the deposit on the NFPM itself:

npm.multicall([
    abi.encodeCall(npm.collect, (INonfungiblePositionManager.CollectParams({
        tokenId: tokenId,
        recipient: user,            // explicitly the user — address(0) means "pay the NFPM"
        amount0Max: type(uint128).max,
        amount1Max: type(uint128).max
    }))),
    abi.encodeCall(npm.safeTransferFrom, (user, address(locker), tokenId, lockData))
]);

The safeTransferFrom is the four-argument overload and lockData is the six-tuple above: a bare three-argument transfer reverts on decode. Both legs keep the user as msg.sender, so this route needs no ERC-721 approval. Fresh mints always have zero tokensOwed, which is why the lock router's mintAndLock is unaffected.

The convenience wrapper

function lock(
    uint256 tokenId,
    address feeRecipient,
    uint64 unlockTime,
    address feeModule,
    bool feeConfigLocked,
    bytes calldata moduleInit
) external;

It is a one-liner over npm.safeTransferFrom(msg.sender, address(this), tokenId, abi.encode(msg.sender, ...)). It hardcodes msg.sender as the lock owner, so you cannot lock a position for somebody else through it. Use the raw safeTransferFrom route with a different owner in data for that.

Because it goes through safeTransferFrom, the caller must first grant the locker an ERC-721 approval: NonfungiblePositionManager.approve(v3Locker, tokenId) or setApprovalForAll. That is a separate transaction. There is no permit path.

It lands in onERC721Received like every deposit, so the PendingTokensOwed check applies here too: a position with pending tokensOwed0/1 cannot come in through lock() either. Collect first, or use the multicall route above.

What the owner can still do while locked

ActionWhoBlocked by
collect(tokenId)anyonelock must exist (NotLocked())
collectMany(uint256[])anyoneany unknown id reverts the whole batch
extendLockownerNotLocked, NotLockOwner, InvalidUnlockTime
setFeeRecipientowneralso FeeConfigIsLocked, ZeroAddress
setFeeModuleowneralso FeeConfigIsLocked, ModuleNotApproved
lockFeeConfigownernothing; it is idempotent
transferLockOwnershipownerZeroAddress
withdrawownerPermanentLock, StillLocked

Nothing else. There is no increaseLiquidity passthrough, no decreaseLiquidity, and no arbitrary-call escape hatch. Once locked, the position's liquidity is frozen at whatever it was at deposit.

The owner-only checks all funnel through one private helper:

function _ownedLock(uint256 tokenId) private view returns (PositionLock storage positionLock) {
    positionLock = positionLocks[tokenId];
    if (positionLock.owner == address(0)) revert NotLocked();
    if (positionLock.owner != msg.sender) revert NotLockOwner();
}

Fee collection

function collect(uint256 tokenId) external returns (uint256 amount0, uint256 amount1);
function collectMany(uint256[] calldata tokenIds) external;

Both are nonReentrant. collect has no access control. Any address may call it: a keeper, a bot, or a stranger. That is deliberate. The caller pays the gas and the money goes wherever the lock's routing says, so there is nothing to gain by cranking somebody else's lock and nothing to lose by letting them.

The internal path:

(amount0, amount1) = npm.collect(
    INonfungiblePositionManager.CollectParams({
        tokenId: tokenId,
        recipient: address(this),
        amount0Max: type(uint128).max,
        amount1Max: type(uint128).max
    })
);
if (amount0 == 0 && amount1 == 0) return (0, 0);
 
address destination = positionLock.feeModule != address(0)
    ? positionLock.feeModule
    : positionLock.feeRecipient;
if (amount0 > 0) IERC20(positionLock.token0).safeTransfer(destination, amount0);
if (amount1 > 0) IERC20(positionLock.token1).safeTransfer(destination, amount1);
 
if (positionLock.feeModule != address(0)) {
    IArmoryFeeModule(positionLock.feeModule).onFeesCollected(
        tokenId, positionLock.token0, positionLock.token1, amount0, amount1
    );
}

Points that matter:

  • The locker always collects the maximum. There is no partial collect and no recipient argument. The destination is derived from the lock record, never from the caller.
  • The locker is a transient holder. It receives from the NFPM and forwards in the same call. Anything left in the locker is a bug in a module or a fee-on-transfer token.
  • A zero-fee collect returns early and emits nothing. Cranking an idle lock is cheap and harmless.
  • collect always collects both sides. If one side is zero, that transfer and nothing else is skipped. The module is still called, with the zero amount passed through as zero.
  • FeesCollected(tokenId, feeDestination, amount0, amount1) names the destination, so an indexer can tell module-routed fees from plain ones without reading state.

A module that reverts stops collection for everyone

collect is one transaction. If the selected module's onFeesCollected reverts, the whole collect reverts, for every caller, until the lock owner rotates the module. If feeConfigLocked is set the lock can never rotate, and on a permanent lock withdraw reverts too, so the fees are stranded.

The shipped splitter cannot be put into that state: an empty or malformed split reverts inside initLock, which fails the deposit rather than creating a half-configured lock. The risk belongs to third-party modules whose initLock accepts a configuration their onFeesCollected will not honour. See Fee modules for the design rule that avoids it.

Where uncollected fees go on withdraw

withdraw transfers the NFT out with whatever fees are still attached to it:

delete positionLocks[tokenId];
npm.transferFrom(address(this), positionLock.owner, tokenId);

Withdraw bypasses the fee recipient and the module

Uncollected fees follow the position to the lock owner. The locker does not crank collect on the way out, so feeRecipient and any fee module are skipped for everything accrued since the last collect. If your product promises "fees go to X forever", either make the lock permanent or call collect(tokenId) immediately before the owner withdraws. On a timed lock, a "fees go to X" claim is only true up to the last collect.

Unlock and withdraw

function withdraw(uint256 tokenId) external;

Reverts NotLocked() for an unknown or already-withdrawn id, NotLockOwner() for anyone but the owner, PermanentLock() when unlockTime is the sentinel, and StillLocked() before the deadline. PermanentLock is checked before the timestamp, so a permanent lock always reports the honest reason.

function extendLock(uint256 tokenId, uint64 newUnlockTime) external;

Owner only. newUnlockTime must exceed both the current unlockTime and block.timestamp. Passing PERMANENT converts a timed lock into a permanent one. That is the only way to make an existing lock permanent, and it cannot be undone. Calling extendLock on an already-permanent lock always reverts InvalidUnlockTime(), because nothing is greater than the sentinel.

An expired-but-unwithdrawn lock can be re-locked with any future timestamp.

Fee configuration

function setFeeRecipient(uint256 tokenId, address newRecipient) external;
function setFeeModule(uint256 tokenId, address newModule, bytes calldata moduleInit) external;
function lockFeeConfig(uint256 tokenId) external;

By default the owner can rotate recipient and module at any time, including on a permanent lock. Fee policy is revenue routing, not principal security, and the two are deliberately separate.

Calling setFeeModule with a zero module and empty init data clears the lock back to plain feeRecipient transfers. A non-zero module must be registry-approved at the moment of the call, and its initLock runs immediately with the supplied bytes. Switching back to a module you used before re-runs initLock, so the module sees a fresh configuration. Do not assume a module retains prior state across a rotation unless its docs say so.

lockFeeConfig is the one-way switch. After it, setFeeRecipient and setFeeModule both revert FeeConfigIsLocked() for the life of the lock. It is idempotent, so calling it twice is legal and does nothing, and it is not itself gated on the flag.

Ossification freezes fee routing only. extendLock and transferLockOwnership still work on an ossified lock, and the frozen policy travels with the lock to the new owner.

Views

function getLock(uint256 tokenId) external view returns (PositionLock memory);
function isFeeConfigLocked(uint256 tokenId) external view returns (bool);
function locksOf(address owner) external view returns (uint256[] memory);

isFeeConfigLocked exists so a module can ask the question without decoding the whole struct. ArmoryFeeSplitter uses it that way.

locksOf is explicitly append-only history. It includes withdrawn locks and locks transferred away, and a lock transferred in appears in the new owner's array while remaining in the old one's. Filter by re-reading positionLocks[id].owner for every id before you render it.

Events

event PositionLocked(
    uint256 indexed tokenId,
    address indexed owner,
    address feeRecipient,
    address feeModule,
    address token0,
    address token1,
    uint64 unlockTime
);
event PositionWithdrawn(uint256 indexed tokenId, address indexed to);
event LockExtended(uint256 indexed tokenId, uint64 newUnlockTime);
event FeesCollected(uint256 indexed tokenId, address indexed feeDestination, uint256 amount0, uint256 amount1);
event FeeRecipientUpdated(uint256 indexed tokenId, address indexed previousRecipient, address indexed newRecipient);
event FeeModuleUpdated(uint256 indexed tokenId, address indexed previousModule, address indexed newModule);
event FeeConfigLocked(uint256 indexed tokenId);
event LockOwnershipTransferred(uint256 indexed tokenId, address indexed previousOwner, address indexed newOwner);

FeeRecipientUpdated and FeeModuleUpdated are emitted before the storage write, so the previous value in each is genuinely the old one.

Full error list

ZeroAddress, OnlyPositionManager, InvalidUnlockTime, PendingTokensOwed, NotLocked, NotLockOwner, StillLocked, PermanentLock, ModuleNotApproved, FeeConfigIsLocked. All zero-argument custom errors.