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.
Two immutables are set at construction and cannot change:
INonfungiblePositionManager public immutable npm;
IFeeModuleRegistry public immutable moduleRegistry;Both are checked non-zero in the constructor (ZeroAddress()).
The lock record
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:
owner == address(0)revertsZeroAddress().unlockTime <= block.timestamprevertsInvalidUnlockTime(). ThePERMANENTsentinel passes.feeRecipient == address(0)defaults toowner.- A non-zero
feeModulemust satisfymoduleRegistry.isApproved(feeModule)or the deposit revertsModuleNotApproved(). token0,token1,tokensOwed0andtokensOwed1are read fromnpm.positions(tokenId). NonzerotokensOwed0ortokensOwed1revertsPendingTokensOwed(). See the callout below.- The record is written and
tokenIdis appended to_locksByOwner[owner]. - 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. PositionLockedis 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
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.
collectalways 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.