Pool fees and AMM
The NFTXV4Hook does two distinct fee jobs:
- Pool fees — collected listing tax flows back to the V4 LPs of the
collection’s pool via the V4
donatemechanic, lifecycle is queue → donate. - AMM fees — a configurable cut taken from the unspecified leg of every
swap that gets escrowed for the
ammBeneficiary(or burnt).
Per-beneficiary FeeOverrides replace the legacy beneficiary-royalty
model and let admins dial in flat swap-fee exemptions.
1. Pool fee deposit lifecycle
Prerequisites
- Caller is one of:
ListingscallingNFTXV4Hook.depositFeesafter capturing tax.- The
Lockeritself (e.g. onunbackedDepositflows that route fees back). - An admin path; arbitrary callers are not blocked, but the call only
succeeds when the caller has approved both
currency0andcurrency1for the relevant amounts.
- The collection has been initialized (otherwise
PoolNotInitialized). - Amounts are denominated in V4-sorted currency order (
currency0 < currency1by address). Caller must resolve orientation themselves — typically by comparingnativeToken()against the collection token address — and pass the fee in the slot that corresponds to the side they are funding.
Inputs
function depositFees(address _collection, uint _amount0, uint _amount1) external;Calls + events
| # | Caller | Call | Event |
|---|---|---|---|
| 1 | Listings (or admin) | <currency0>.approve(hook, _amount0) | — |
| 2 | Listings (or admin) | <currency1>.approve(hook, _amount1) | — |
| 3 | Listings (or admin) | NFTXV4Hook.depositFees(collection, amount0, amount1) | PoolFeesQueued(collection, amount0, amount1) |
| 4 | Anyone swapping | Subsequent PoolManager.swap callback unlocks the queued amounts | PoolFeesDonated(collection, amount0, amount1) |
The donate happens automatically on the next swap that touches the pool — there is no separate “trigger donate” entry-point.
Code (queue path)
depositFees.ts
import { nftxV4HookAbi } from "@nftx/contracts";
import { NFTX_CONTRACTS } from "@nftx/config";
import { parseUnits } from "viem";
const hook = NFTX_CONTRACTS[base.id].nftxV4Hook;
const collection = "0xCollection";
// Sort the amounts into (currency0, currency1) order off-chain.
const collectionToken = await publicClient.readContract({
address: NFTX_CONTRACTS[base.id].locker,
abi: lockerAbi,
functionName: "collectionToken",
args: [collection],
});
const native = await publicClient.readContract({
address: hook,
abi: nftxV4HookAbi,
functionName: "nativeToken",
});
const isNativeFirst =
BigInt(native.toLowerCase()) < BigInt(collectionToken.toLowerCase());
const nativeAmount = parseUnits("0.1", 18);
const tokenAmount = parseUnits("0.0", 18);
await walletClient.writeContract({
address: hook,
abi: nftxV4HookAbi,
functionName: "depositFees",
args: [
collection,
isNativeFirst ? nativeAmount : tokenAmount,
isNativeFirst ? tokenAmount : nativeAmount,
],
});Failure modes
| Error | Cause |
|---|---|
UnknownCollection | _collection not registered |
PoolNotInitialized | Pool not yet initialized via initializeCollection |
| ERC-20 allowance revert | Caller didn’t approve currency0 / currency1 |
Subgraph follow-through
| Event | Effect |
|---|---|
PoolFeesQueued | PoolFees.queuedAmount0/1 += amount |
PoolFeesDonated | PoolFees.queuedAmount0/1 cleared by amount; totalDonated0/1 accumulates; new PoolFeeDonation row written |
query PoolFeesNow($chainId: Int!, $collection: String!) {
PoolFees(
where: {
chainId: { _eq: $chainId }
collection: { collection: { _eq: $collection } }
}
limit: 1
) {
queuedAmount0
queuedAmount1
totalDonated0
totalDonated1
updatedAt
}
}2. AMM fee + beneficiary
The AMM cut is configured globally and per-pool LP fee is configured per-pool:
| Call | Effect | Event |
|---|---|---|
setDefaultFee(uint24) | Sets the default LP fee | DefaultFeeSet(_fee) |
setFee(PoolId, uint24) | Per-pool LP override | PoolFeeSet(collection, _fee) |
setAmmFee(uint24) | Global AMM cut | AMMFeeSet(_ammFee) |
setAmmBeneficiary(address) | Recipient (or address(0) to burn) | AMMBeneficiarySet(_ammBeneficiary) |
setFeeExemption(address, uint24) | Per-beneficiary flat fee | FeeOverrideSet(beneficiary, flatFee) |
removeFeeExemption(address) | Clear an exemption | FeeOverrideRemoved(beneficiary) |
All six are admin-only.
When a swap occurs, the hook computes the resolved fee via
getFee(poolId, tx.origin):
- If
tx.originhas an activefeeOverride→ use the override. - Else if the pool has a
poolFee != 0→ use that. - Else use
defaultFee.
The AMM cut is taken from the unspecified leg and credited to
ammBeneficiary via the inherited TokenEscrow. Recipients claim with
NFTXV4Hook.withdraw(token, amount).
Code (admin path)
adminFees.ts
import { nftxV4HookAbi } from "@nftx/contracts";
import { NFTX_CONTRACTS } from "@nftx/config";
const hook = NFTX_CONTRACTS[base.id].nftxV4Hook;
await walletClient.writeContract({
address: hook,
abi: nftxV4HookAbi,
functionName: "setDefaultFee",
args: [10_000], // 1.0%
});
await walletClient.writeContract({
address: hook,
abi: nftxV4HookAbi,
functionName: "setAmmFee",
args: [3_000], // 0.3%
});
await walletClient.writeContract({
address: hook,
abi: nftxV4HookAbi,
functionName: "setAmmBeneficiary",
args: ["0xTreasury"],
});
await walletClient.writeContract({
address: hook,
abi: nftxV4HookAbi,
functionName: "setFeeExemption",
args: ["0xRouter", 1_000], // pin router swaps to 0.1%
});Failure modes
| Error | Cause |
|---|---|
FeeExemptionInvalid(_invalidFee, _maxFee) | Override above the V4 max LP fee |
NoBeneficiaryExemption(_beneficiary) | removeFeeExemption for a beneficiary with no exemption |
| ownership revert | Caller isn’t the hook owner |
Withdrawing AMM fees
import { nftxV4HookAbi } from "@nftx/contracts";
await walletClient.writeContract({
address: hook,
abi: nftxV4HookAbi,
functionName: "withdraw",
args: [token, amount],
});Subgraph follow-through
| Event | Effect |
|---|---|
DefaultFeeSet | Config.defaultFee updates |
PoolFeeSet | Collection.poolFee updates |
AMMFeeSet / AMMBeneficiarySet | Config.ammFee / Config.ammBeneficiary |
AMMFeesTaken | New AmmFeeTaken log row |
FeeOverrideSet / FeeOverrideRemoved | FeeOverride row upsert / flatFee cleared |
Deposit / Withdrawal | TokenEscrow row upserts (same entity that backs Listings refunds) |
query MyAmmFees($chainId: Int!, $payee: String!) {
TokenEscrow(
where: {
chainId: { _eq: $chainId }
payee: { _eq: $payee }
amount: { _gt: "0" }
}
) {
token
amount
updated
}
}