Documentation

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.

ContractAddressApeScan
ArmoryFeeModuleRegistry0xA9De…0A23
ArmoryFeeSplitter0x5616…1B4b
ArmoryV3Locker0x98B8…d123

The interface

IArmoryFeeModule.sol
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

ArmoryFeeModuleRegistry.sol
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

  1. Deploy your module. It should be verified on the explorer before you ask.
  2. The platform multisig calls setApproved(module, true).
  3. From that block, any lock may name your module at deposit or rotate onto it with setFeeModule.
  4. setApproved(module, false) stops new selections. Locks already pointing at the module keep working, because the locker only consults the registry in onERC721Received and setFeeModule, never in collect.

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 onFeesCollected and brick collect for its locks, permanently if the lock is ossified.
  • Consume unbounded gas inside collect, making the crank expensive or impossible.
  • Lie in initLock by 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 against msg.sender.
  • Re-enter collect or collectMany, since both are nonReentrant.
  • 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

ArmoryFeeSplitter.sol
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.

MyFeeModule.sol
// 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 onFeesCollected reaches zero remaining balance.
  • No unbounded loop, and no external call that can revert for reasons outside your control.
  • onFeesCollected tolerates a zero amount0 and a zero amount1 independently.
  • A lock cannot reach "selected but unconfigured". Either initLock rejects the empty case, or onFeesCollected degrades 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.