User journeysPool fees & AMM

Pool fees and AMM

The NFTXV4Hook does two distinct fee jobs:

  1. Pool fees — collected listing tax flows back to the V4 LPs of the collection’s pool via the V4 donate mechanic, lifecycle is queue → donate.
  2. 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:
    • Listings calling NFTXV4Hook.depositFees after capturing tax.
    • The Locker itself (e.g. on unbackedDeposit flows that route fees back).
    • An admin path; arbitrary callers are not blocked, but the call only succeeds when the caller has approved both currency0 and currency1 for the relevant amounts.
  • The collection has been initialized (otherwise PoolNotInitialized).
  • Amounts are denominated in V4-sorted currency order (currency0 < currency1 by address). Caller must resolve orientation themselves — typically by comparing nativeToken() 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

#CallerCallEvent
1Listings (or admin)<currency0>.approve(hook, _amount0)
2Listings (or admin)<currency1>.approve(hook, _amount1)
3Listings (or admin)NFTXV4Hook.depositFees(collection, amount0, amount1)PoolFeesQueued(collection, amount0, amount1)
4Anyone swappingSubsequent PoolManager.swap callback unlocks the queued amountsPoolFeesDonated(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

ErrorCause
UnknownCollection_collection not registered
PoolNotInitializedPool not yet initialized via initializeCollection
ERC-20 allowance revertCaller didn’t approve currency0 / currency1

Subgraph follow-through

EventEffect
PoolFeesQueuedPoolFees.queuedAmount0/1 += amount
PoolFeesDonatedPoolFees.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:

CallEffectEvent
setDefaultFee(uint24)Sets the default LP feeDefaultFeeSet(_fee)
setFee(PoolId, uint24)Per-pool LP overridePoolFeeSet(collection, _fee)
setAmmFee(uint24)Global AMM cutAMMFeeSet(_ammFee)
setAmmBeneficiary(address)Recipient (or address(0) to burn)AMMBeneficiarySet(_ammBeneficiary)
setFeeExemption(address, uint24)Per-beneficiary flat feeFeeOverrideSet(beneficiary, flatFee)
removeFeeExemption(address)Clear an exemptionFeeOverrideRemoved(beneficiary)

All six are admin-only.

When a swap occurs, the hook computes the resolved fee via getFee(poolId, tx.origin):

  1. If tx.origin has an active feeOverride → use the override.
  2. Else if the pool has a poolFee != 0 → use that.
  3. 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

ErrorCause
FeeExemptionInvalid(_invalidFee, _maxFee)Override above the V4 max LP fee
NoBeneficiaryExemption(_beneficiary)removeFeeExemption for a beneficiary with no exemption
ownership revertCaller 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

EventEffect
DefaultFeeSetConfig.defaultFee updates
PoolFeeSetCollection.poolFee updates
AMMFeeSet / AMMBeneficiarySetConfig.ammFee / Config.ammBeneficiary
AMMFeesTakenNew AmmFeeTaken log row
FeeOverrideSet / FeeOverrideRemovedFeeOverride row upsert / flatFee cleared
Deposit / WithdrawalTokenEscrow 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
  }
}