Fee modules
The IArmoryFeeModule extension point, the approval registry, the security model, and ArmoryFeeSplitter as a worked reference implementation.
On this page
A fee module is a contract that decides what happens to a locked V3 position's swap fees after the locker has collected them. This is the extension point. If your token needs fees split six ways, burned, streamed into a staking contract, or converted and forwarded, you write a module rather than forking the locker.
The interface
interface IArmoryFeeModule {
function initLock(uint256 tokenId, address owner, bytes calldata data) external;
function onFeesCollected(uint256 tokenId, address token0, address token1, uint256 amount0, uint256 amount1)
external;
}That is the whole build target. Two functions, no return values, no ERC-165.
initLock
Called by: ArmoryV3Locker only, from onERC721Received when a lock names
your module at deposit, and from setFeeModule when a lock rotates onto it.
Receives: the NFPM tokenId the lock is keyed by, the lock's owner at the
moment of the call, and data, arbitrary bytes supplied by whoever created or
rotated the lock. That payload is moduleInitData in the deposit encoding,
moduleData in ArmoryLockRouter.MintAndLockParams, and moduleInit in
ArmoryV3Locker.lock and setFeeModule.
Must: validate that msg.sender is the locker, decode data, store the
per-lock configuration, and revert if the configuration is unusable. A revert
here fails the entire deposit or rotation, which is the correct place to fail.
It is the last moment at which the user can still fix their input.
May be called more than once for the same tokenId. A rotation away and
back re-runs it. Treat it as "configure", not "initialize once".
Do not trust owner beyond the current call. It is the owner at
initialization time. transferLockOwnership can change it afterwards without
notifying the module. If you need the live owner, read
ArmoryV3Locker.getLock(tokenId).owner.
onFeesCollected
Called by: the locker's internal collect path, after it has already
transferred amount0 of token0 and amount1 of token1 to your module's
address. The tokens are in your balance before the call arrives. This is a
notification with a payload description, not a request for a pull.
Receives: the lock's cached token0 and token1, read from
npm.positions at deposit, plus the exact amounts just transferred. Either
amount may be zero. The locker returns early only when both are zero, so a
single-sided collect still calls you.
Must not: assume a fixed pair, because a module is generalized across every
pair the locker holds. It must not assume decimals. It must not assume it is the
only holder of those tokens: a module used by many locks holds a commingled
balance and has to key everything on tokenId.
Must not revert on a legitimate collect. A revert here reverts the whole
collect, permanently if the lock is ossified.
Reentrancy. collect and collectMany are nonReentrant on the locker, so
your module cannot re-enter collect from inside onFeesCollected. The attempt
reverts, taking the collect with it. initLock, however, is reached through
onERC721Received and setFeeModule, neither of which is nonReentrant. A
module can call back into the locker during initLock. It does so as itself, so
_ownedLock rejects every owner-gated function unless the module genuinely is
the lock owner. Do not rely on reentrancy protection you did not write.
Gas. Your module runs inside every collect for every lock that selects it.
collectMany multiplies that by the batch size. An unbounded loop over a
user-supplied array is how a keeper batch stops fitting in a block. Bound your
configuration size at initLock.
The registry
contract ArmoryFeeModuleRegistry is ApeOwnable {
mapping(address => bool) public isApproved;
event ModuleApproved(address indexed module, bool approved);
function setApproved(address module, bool approved) external onlyOwner {
require(module != address(0), 'Zero Address');
isApproved[module] = approved;
emit ModuleApproved(module, approved);
}
}That is the entire contract. onlyOwner comes from ApeOwnable, which reads
GovernanceManager.owner() live, the platform Safe, rather than storing an
owner of its own. There is no timelock, no proposal queue, and no
self-registration path. setApproved takes effect in the same block, in both
directions.
Approval lifecycle
- Deploy your module. It should be verified on the explorer before you ask.
- The platform multisig calls
setApproved(module, true). - From that block, any lock may name your module at deposit or rotate onto it
with
setFeeModule. setApproved(module, false)stops new selections. Locks already pointing at the module keep working, because the locker only consults the registry inonERC721ReceivedandsetFeeModule, never incollect.
Point 4 is the one to understand before you ship. Un-approval is not a kill switch. Once a lock has selected your module, fees keep flowing to it, and if that lock is ossified nobody can move them. Not the owner, not the multisig.
Check module approval before offering it
isApproved is a plain public getter. Call
feeModuleRegistry.isApproved(module) when you render the module picker, and
gate the submit button on the result. A UI that assumes approval sends users
into a ModuleNotApproved() revert; a UI that assumes rejection hides a working
feature. The check lives in the locker, so there is no workaround for a module
that is genuinely unapproved.
Security model
What a malicious module can do
- Keep every token the locker forwards to it. Fees routed to a module are gone from the lock owner's control the moment the transfer executes. The module is the destination, not a custodian with rules.
- Revert
onFeesCollectedand brickcollectfor its locks, permanently if the lock is ossified. - Consume unbounded gas inside
collect, making the crank expensive or impossible. - Lie in
initLockby accepting any configuration and ignoring it.
What a malicious module cannot do
- Touch the position NFT. The locker never approves, transfers, or delegates the ERC-721 to anyone, and holds no code path that would.
- Decrease liquidity, burn the position, or shorten a lock.
- Reach fees belonging to locks that did not select it. The collect path sends
only the amounts from that
tokenId's own collect. - Change a lock's owner, recipient, module, or unlock time. Every one of those
is behind
_ownedLock, which compares againstmsg.sender. - Re-enter
collectorcollectMany, since both arenonReentrant. - Approve itself in the registry.
What the registry protects against
Exactly one thing: a lock owner pointing their fee stream at a broken or malicious contract by mistake or by social engineering. The curation is a whitelist of destinations, not a guarantee about a module's behaviour, and it does not protect a lock that has already selected a module. The per-lock module choice always belongs to the lock owner. The registry only bounds the menu.
Worked example: ArmoryFeeSplitter
The first-party module. It splits a locked position's collected fees between any number of recipients by share, in whatever tokens arrive. A burn is not a special case: it is an ordinary leg pointed at the burn address.
Wiring
contract ArmoryFeeSplitter is IArmoryFeeModule {
using SafeERC20 for IERC20;
ArmoryV3Locker public immutable locker;
struct Split {
address[] recipients;
uint16[] bps; // sums to MAX_BPS
}
mapping(uint256 tokenId => Split) private _splits;
constructor(address locker_) {
require(locker_ != address(0), 'Zero Address');
locker = ArmoryV3Locker(locker_);
}
}MAX_BPS is a public uint16 constant on the splitter: the denominator every
leg's share is expressed against. Read it with MAX_BPS() when building a
split.
The locker is immutable and singular. A module is bound to one locker at
deploy. There is no multi-locker registry lookup and no way to repoint it. That
is what makes the msg.sender check below sufficient. It also means a new
locker deployment needs a new module deployment and a new approval.
Configuration is keyed by tokenId in a mapping, not stored in a single
contract-wide slot, because the module is shared by every lock that selects it.
Accepting configuration
function initLock(uint256 tokenId, address, bytes calldata data) external {
if (msg.sender != address(locker)) revert OnlyLocker();
_configure(tokenId, data);
}Note what is not here: the owner argument is discarded. The splitter never
caches it, so transferLockOwnership cannot desynchronize it. It reads the live
owner instead when it needs one.
The wire format is abi.encode(address[] recipients, uint16[] bps):
function _configure(uint256 tokenId, bytes memory data) private {
(address[] memory recipients, uint16[] memory bps) = abi.decode(data, (address[], uint16[]));
if (recipients.length == 0 || recipients.length != bps.length) revert BadSplit();
uint256 total;
for (uint256 i = 0; i < recipients.length; i++) {
if (recipients[i] == address(0)) revert BadSplit();
if (bps[i] == 0) revert BadSplit();
total += bps[i];
}
if (total != MAX_BPS) revert BadSplit();
_splits[tokenId] = Split({recipients: recipients, bps: bps});
emit SplitConfigured(tokenId, recipients, bps);
}Five failure modes collapse to one BadSplit(): an empty array, a length
mismatch, a zero recipient, a zero share, and a sum that is not exactly
MAX_BPS. Validate each condition client-side so the user sees a useful error.
Duplicate recipients are legal. An address that appears twice receives the sum of its legs.
The bps array is uint16, not uint256
abi.decode(data, (address[], uint16[])) reverts on a payload encoded with
uint256[]. Because that revert happens inside initLock, a lock created with
malformed module data cannot be created at all, which is the good case. The bad
case is an empty payload. See the footgun below.
Letting the owner retune
function setSplit(uint256 tokenId, address[] calldata recipients, uint16[] calldata bps) external {
if (locker.getLock(tokenId).owner != msg.sender) revert OnlyLockOwner();
if (locker.isFeeConfigLocked(tokenId)) revert FeeConfigIsLocked();
_configure(tokenId, abi.encode(recipients, bps));
}This is the pattern to copy for any owner-mutable module setting. The module
does not maintain its own notion of ownership or its own freeze flag. It asks
the locker for both, every time. That is why ossifying a lock with
lockFeeConfig also freezes the split, even though lockFeeConfig knows
nothing about the splitter.
An unknown tokenId returns a zero-owner struct from getLock, so setSplit
on a lock that does not exist reverts OnlyLockOwner().
Distributing
function onFeesCollected(uint256 tokenId, address token0, address token1, uint256 amount0, uint256 amount1)
external
{
if (msg.sender != address(locker)) revert OnlyLocker();
Split memory split = _splits[tokenId];
if (split.recipients.length == 0) revert NotConfigured();
if (amount0 > 0) _distribute(tokenId, token0, amount0, split);
if (amount1 > 0) _distribute(tokenId, token1, amount1, split);
}function _distribute(uint256 tokenId, address token, uint256 amount, Split memory split) private {
uint256 remaining = amount;
uint256 last = split.recipients.length - 1;
for (uint256 i = 0; i < split.recipients.length; i++) {
// last leg takes the remainder so rounding dust never accumulates here
uint256 share = i == last ? remaining : (amount * split.bps[i]) / MAX_BPS;
remaining -= share;
if (share > 0) IERC20(token).safeTransfer(split.recipients[i], share);
}
emit SplitPaid(tokenId, token, amount);
}The remainder trick is the part worth copying. Integer division leaves dust. If
every leg computes its own share independently, the sum is at most amount and
the difference accrues in the module forever. Giving the final leg remaining
guarantees the module's balance for that token returns to zero. The locker test
suite asserts exactly that.
The Split memory copy is loaded once and passed down rather than re-read per
leg, which is worth doing when the same struct serves two tokens in one call.
The footgun: an unconfigured module reverts collect() forever
onFeesCollected reverts NotConfigured() when a lock selected the splitter but
no split was ever set. Because collect is permissionless and one transaction,
that means nobody can collect that lock's fees. If the lock is also ossified,
nobody ever will be able to.
The shape to design against is reachable in a single transaction: call
mintAndLock with the module set, moduleData empty, and feeConfigLocked
true. For this splitter the abi.decode of empty bytes reverts, so the deposit
itself fails, which is the safe outcome. Any module whose initLock is more
permissive than its onFeesCollected reaches the bad state instead: module
selected, module unconfigured, configuration frozen. At that point the lock's
fees are unreachable by anyone, forever.
Validate that module configuration is complete before allowing a lock to become permanent.
Views and events
function getSplit(uint256 tokenId) external view returns (address[] memory recipients, uint16[] memory bps);
event SplitConfigured(uint256 indexed tokenId, address[] recipients, uint16[] bps);
event SplitPaid(uint256 indexed tokenId, address indexed token, uint256 amount);Errors: OnlyLocker, OnlyLockOwner, FeeConfigIsLocked, BadSplit,
NotConfigured.
A skeleton to start from
Copy this, replace the config type and the policy, and keep every guard. The highlighted lines are the ones that make the authorization story complete.
// 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 {IArmoryFeeModule} from "../interfaces/IArmoryFeeModule.sol";
import {ArmoryV3Locker} from "../ArmoryV3Locker.sol";
contract MyFeeModule is IArmoryFeeModule {
using SafeERC20 for IERC20;
/// Bound this. Your loop runs inside every collect(), and inside
/// collectMany() once per lock in the batch.
uint256 public constant MAX_ENTRIES = 16;
/// One locker, fixed at deploy. This is what makes the msg.sender check
/// below a complete authorization story.
ArmoryV3Locker public immutable locker;
struct Config {
address destination;
// ... your policy
}
/// Keyed by tokenId: one module instance serves every lock that picks it.
mapping(uint256 tokenId => Config) private _configs;
event Configured(uint256 indexed tokenId, address destination);
event Handled(uint256 indexed tokenId, address indexed token, uint256 amount);
error OnlyLocker();
error OnlyLockOwner();
error FeeConfigIsLocked();
error BadConfig();
error NotConfigured();
constructor(address locker_) {
require(locker_ != address(0), 'Zero Address');
locker = ArmoryV3Locker(locker_);
}
// -------------------------------------------------------- IArmoryFeeModule
/// Called on deposit and on every setFeeModule rotation onto this module.
/// Reverting here fails the deposit, which is the right time to fail.
function initLock(uint256 tokenId, address /*owner*/, bytes calldata data) external {
if (msg.sender != address(locker)) revert OnlyLocker();
_configure(tokenId, data);
}
/// Called AFTER the locker has already transferred the amounts to us.
/// Never revert on a legitimate collect: collect() is permissionless and
/// one transaction, and an ossified lock can never rotate away from us.
function onFeesCollected(uint256 tokenId, address token0, address token1, uint256 amount0, uint256 amount1)
external
{
if (msg.sender != address(locker)) revert OnlyLocker();
Config memory config = _configs[tokenId];
if (config.destination == address(0)) revert NotConfigured();
if (amount0 > 0) _handle(tokenId, token0, amount0, config);
if (amount1 > 0) _handle(tokenId, token1, amount1, config);
}
// ------------------------------------------------------------ owner surface
/// Ask the locker who the owner is and whether the lock is ossified.
/// Never cache either: transferLockOwnership and lockFeeConfig do not
/// notify modules.
function setConfig(uint256 tokenId, address destination) external {
if (locker.getLock(tokenId).owner != msg.sender) revert OnlyLockOwner();
if (locker.isFeeConfigLocked(tokenId)) revert FeeConfigIsLocked();
_configure(tokenId, abi.encode(destination));
}
function getConfig(uint256 tokenId) external view returns (Config memory) {
return _configs[tokenId];
}
// ---------------------------------------------------------------- internals
function _configure(uint256 tokenId, bytes memory data) private {
address destination = abi.decode(data, (address));
if (destination == address(0)) revert BadConfig();
_configs[tokenId] = Config({destination: destination});
emit Configured(tokenId, destination);
}
/// Forward everything. A module that keeps a remainder accrues dust it can
/// never spend, so make the arithmetic exhaustive.
function _handle(uint256 tokenId, address token, uint256 amount, Config memory config) private {
IERC20(token).safeTransfer(config.destination, amount);
emit Handled(tokenId, token, amount);
}
}Checklist before you ask for approval
msg.sender == address(locker)on both interface methods.- Configuration keyed by
tokenId, never global. - Owner and ossification read live from the locker, never cached.
- Every code path in
onFeesCollectedreaches zero remaining balance. - No unbounded loop, and no external call that can revert for reasons outside your control.
onFeesCollectedtolerates a zeroamount0and a zeroamount1independently.- A lock cannot reach "selected but unconfigured". Either
initLockrejects the empty case, oronFeesCollecteddegrades gracefully instead of reverting.
Once the module is deployed and verified, the approval request goes to the platform multisig. Nothing in the flow is self-service.