Skip to content

feat: add V4 fee adapter with policy-based waterfall and automated classification#133

Open
ccashwell wants to merge 26 commits into
mainfrom
v4-adapter
Open

feat: add V4 fee adapter with policy-based waterfall and automated classification#133
ccashwell wants to merge 26 commits into
mainfrom
v4-adapter

Conversation

@ccashwell

@ccashwell ccashwell commented Apr 6, 2026

Copy link
Copy Markdown
Member

Adds the V4 leg of the protocol-fee system: a long-lived V4FeeAdapter registered with v4-core's PoolManager as the protocolFeeController, and a replaceable V4FeePolicy that resolves fees via either a piecewise-linear bucket schedule (vanilla pools) or family-based classification (hook-using and dynamic-fee pools). Per-pair fees are stored in pairClassFees[pairHash][familyId] so aggregator rollout and native-math rollout do not share a single pair-level knob. Permissionless triggerFeeUpdate propagates resolved fees onto pools; permissionless collect routes accrued protocol fees to the immutable TOKEN_JAR.

Files

Path Purpose
src/feeAdapters/V4FeeAdapter.sol The PoolManager's registered protocolFeeController. Pool-level overrides, policy delegation, permissionless triggerFeeUpdate / batchTriggerFeeUpdate / collect.
src/feeAdapters/V4FeePolicy.sol Pluggable fee-resolution policy. Bucket schedule (Path A) + family classification (Path B) with per-family pair class fees.
src/interfaces/IV4FeeAdapter.sol, IV4FeePolicy.sol Interfaces, shared structs (FeeBucket, FlagRule).
src/interfaces/IFeeClassifiedHook.sol Optional self-classification interface for hooks.
src/libraries/HookFeeFlags.sol 12-flag behavioral vocabulary across value-extraction / strategy / integration buckets.
test/V4FeeAdapter.t.sol 115 unit tests against MockV4PoolManager.
test/V4FeeAdapter.fork.t.sol 11 integration tests against a real v4 PoolManager via Deployers.
snapshots/V4FeeAdapter*.json Gas snapshots, isolated mode (matches CI).
README.md V4 architecture section + ASCII waterfall + role/cap summary.
audit/openzeppelin-4.pdf OpenZeppelin audit report for the V4 fee adapter stack.

Net diff vs main: +3535 / −8.

Mental model

The adapter is a scheduler + collector, not on the swap path. It is never called during a swap.

swapper                                                LP
  │                                                    │
  └──► PoolManager.swap (only inside unlock())         │
         │                                             │
         ├──► takes lpFee from swap proceeds  ────────► (LP's portion)
         │
         └──► IF Slot0.protocolFee != 0:
                takes protocolFee from swap proceeds
                  │
                  └──► protocolFeesAccrued[currency] += amount

Slot0.protocolFee is only set because someone previously called adapter.triggerFeeUpdate(key), which called PoolManager.setProtocolFee(key, getFee(key)) with the value returned by the adapter+policy waterfall. v4-core never asks the controller for a fee — controllers only push. New pools start at protocol fee 0 and stay there until triggered.

   (anyone calls adapter.collect at any time)
                  │
                  ▼
            PoolManager.collectProtocolFees(TOKEN_JAR, currency, amount)
                  │
                  └──► tokens delivered to the immutable TokenJar

Both triggerFeeUpdate and collect are permissionless because their effects are bounded by adapter/policy state (governance-controlled) and the immutable TOKEN_JAR destination.

Fee resolution waterfall

adapter.poolOverrides[poolId]   ──►  return (sentinel-decoded)
        │
        └──►  policy.computeFee(key)
                │
                ├── Path A: StaticNativeMath ──►  pairClassFees[pair][NATIVE_MATH_FAMILY_ID]  OR
                │                                 walk feeBuckets backward, find largest
                │                                 lpFeeFloor <= key.fee (snap to bucket 0
                │                                 if key.fee < floor_0); return
                │                                 alpha + beta × (key.fee - floor) / 1_000_000
                │
                └── Path B: Classified ────────►  familyId from _resolveFamily(hook)
                                                  (hookFamilyId  OR  flag-rule match)
                                                  │
                                                  ├─ family == 0 ──►  defaultFee
                                                  │
                                                  └─ family > 0 ──►  pairClassFees[pair][family]  OR
                                                                     familyDefaults[family]  OR
                                                                     defaultFee

A pool takes Path A when the hook has no *_RETURNS_DELTA flags AND key.fee is static; Path B otherwise. key.fee is unreliable on Path B (dynamic fees can change every swap; custom-accounting hooks rewrite swap deltas), so the bucket schedule does not apply there.

NATIVE_MATH_FAMILY_ID (0) is a reserved slot in pairClassFees for StaticNativeMath pair overrides only. It is not the same as hookFamilyId == 0 on Path B, which means unclassified and falls through to defaultFee. Path B never reads pairClassFees[pair][0].

Both paths use MULTIPLIER_DENOMINATOR = 1_000_000 for bucket slope math and the per-direction MAX_PROTOCOL_FEE = 1000 clamp from v4-core's ProtocolFeeLibrary. v4-core re-validates the value pushed via setProtocolFee (defense in depth).

Path A — bucket schedule

Each FeeBucket is (uint24 lpFeeFloor, uint24 alphaPips, uint32 betaPips) packed into a single storage slot:

  • alpha — flat per-direction base fee, capped at MAX_PROTOCOL_FEE (1000).
  • beta — slope in pips of protocol fee per pip of (lpFee - floor), capped at 1_000_000_000.
  • beta = 0 → step function. alpha = 0 → slope-only. Both nonzero → general piecewise-linear.
  • Continuity at boundaries is governance's responsibility — not enforced by the contract.
  • lpFee < floor_0 snaps to bucket 0 with delta = 0, so alpha_0 doubles as a minimum-fee floor for very-low-LP-fee pools (race-to-the-bottom defense without an explicit minFee field).
  • Capped at 16 buckets (MAX_BUCKETS); set atomically via setFeeBuckets, cleared via clearFeeBuckets.

Optional Path A pair override: setPairClassFee(c0, c1, NATIVE_MATH_FAMILY_ID, fee) — same behavior as the old flat pairFees, but scoped to the native-math slot so classified families cannot inherit it.

Path B — family classification

_resolveFamily(hook):

  1. Governance overridehookFamilyId[hook] wins if non-zero.
  2. Self-report — if any flag rules are configured, gas-capped (30k) staticcall to hook.protocolFeeFlags(). Walk _flagRules in array order; first rule whose requiredFlags are all set in the hook's flags wins.
  3. OtherwisefamilyId = 0defaultFee only.

With family > 0, the policy returns pairClassFees[ph][family] if set (literal fee, no scaling), else familyDefaults[family], else defaultFee. This supports per-pair fees for one family (e.g. aggregator) without affecting other families or native-math pools on the same token pair.

Self-report is opt-in: hooks not implementing IFeeClassifiedHook.protocolFeeFlags() land at family = 0 unless pinned via setHookFamily. Failure modes (revert / OOG / bad return data / no code) all gracefully fall through.

Sentinel encoding

pairClassFees, familyDefaults, defaultFee, and poolOverrides use ZERO_FEE_SENTINEL = type(uint24).max to distinguish "explicit zero" (short-circuits the waterfall to 0) from "not set" (falls through to the next layer). The sentinel is provably never a valid protocol fee — both 12-bit components decode to 4095, and isValidProtocolFee requires each ≤ 1000 — so there is no collision risk.

Roles

  • owner — Solmate Owned, single-step. Swaps the policy (adapter only) and rotates the fee-setter on each contract. Adapter and policy have independent owner slots, so rotation is two transactions.
  • feeSetter — operational governance. On the adapter: pool overrides. On the policy: bucket schedule, pair class fees (setPairClassFee / clearPairClassFee), hook families, flag rules, family defaults, defaultFee.
  • anyonetriggerFeeUpdate, batchTriggerFeeUpdate, collect, all view methods.

Push-only fee setting

v4-core's PoolManager.initialize does not call into the protocolFeeController (verified at lib/v4-core/src/libraries/Pool.sol:105 — "the initial protocolFee is 0 so doesn't need to be set"). Pools start at protocol fee 0 and stay there until someone explicitly pushes a value via triggerFeeUpdate. Implications:

  • We will run a keeper that watches Initialize, PolicyUpdated, FeeBucketsUpdated, PairClassFeeUpdated, PoolOverrideUpdated, etc., and triggers affected pools. Anyone can do this — it's a pure liveness role.
  • Discovery is off-chain. v4 doesn't enumerate PoolKeys on-chain; the keeper indexes PoolManager.Initialize events to learn them.
  • After any governance config change, fees on the PoolManager are stale until the corresponding triggerFeeUpdate lands. Operational mitigation: bundle config + trigger in a single multicall.

Deliberate design choices

These are conscious tradeoffs, not oversights. Calling them out so reviewers don't suggest changing them:

  1. Single-step Solmate Owned. Governance is trusted to handle ownership rotation correctly.
  2. No pause(). Owner can effectively pause/disable fees via setPolicy(address(0)) + batchTriggerFeeUpdate(allPools).
  3. Per-family pair fees instead of a global pairFees + multiplier. Avoids coupling aggregator per-pair tuning to native-math pools and other classified families. Fees are literal per (pair, family); no discount/premium multiplier layer.
  4. NATIVE_MATH_FAMILY_ID reuses uint8 0 in a different namespace from hookFamilyId == 0. Documented on-chain constant; Path B never indexes the native slot.
  5. TOKEN_JAR is immutable, not validated at construction. Deployment-time concern; deploy script handles the invariant.
  6. batchTriggerFeeUpdate and collect are unbounded. Caller bears their own gas.
  7. No on-chain enforcement that adapter.owner == policy.owner. Deploy script handles coordinated ownership transfer.
  8. Hook self-report is opt-in and unauthenticated. Governance-configured flag rules MUST only point at families with fees ≥ the intended floor. High-stakes hooks are pinned via setHookFamily directly.
  9. triggerFeeUpdate is permissionless even immediately after a config change. Bounded by MAX_PROTOCOL_FEE = 0.1%.

Notes

The branch evolved through several fee-resolution iterations (curve → single multiplier → piecewise-linear buckets → per-family pairClassFees) as governance refined rollout requirements (native curve vs aggregator per-pair vs classified families). The final shape lives in the current V4FeePolicy; squash-merging is fine since this lands as one PR.

@ccashwell ccashwell force-pushed the v4-adapter branch 2 times, most recently from b904aaf to 42bb847 Compare April 6, 2026 23:58
@ccashwell ccashwell marked this pull request as draft April 8, 2026 21:08
@ccashwell ccashwell requested a review from marktoda April 8, 2026 21:11
Comment thread src/feeAdapters/V4FeeAdapter.sol Outdated
Comment thread src/feeAdapters/V4FeePolicy.sol Outdated
Comment thread src/feeAdapters/V4FeePolicy.sol
Comment thread src/interfaces/IV4FeePolicy.sol Outdated
Comment thread src/feeAdapters/V4FeeAdapter.sol Outdated
Comment thread src/feeAdapters/V4FeeAdapter.sol
Comment thread src/feeAdapters/V4FeeAdapter.sol
/// storage means "not set" rather than "explicitly zero".
/// @param feeValue The actual fee value (0 = remove/unset).
/// @return The encoded value to store.
function _encodeFee(uint24 feeValue) internal pure returns (uint24) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_encodeFee, _decodeFee and _validateFee are all defined in both contracts. Would be great to have a fee library instead that both contracts use fee.decode() fee.encode() fee.validate()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in fact you could not bother with validate, and call the protocol fee library inline instead, that way the error emitted can have better parameters available in the higher level function call?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh both contracts also have a feeSetter, and onlyFeeSetter. Maybe more stuff too? Is it worth having a base contract with common stuff?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

feels kind of weird that the 2 contracts might have different fee setter addresses? or is that intentional?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was intentional with the idea being that a future version of the v4 policy could be the sole entrypoint to fee configuration while deferring the complexity of a potentially multi-role design to a later date

Comment thread src/feeAdapters/V4FeeAdapter.sol
Comment thread src/feeAdapters/V4FeeAdapter.sol
Comment thread src/feeAdapters/V4FeeAdapter.sol
Comment thread src/feeAdapters/V4FeePolicy.sol
Comment thread src/feeAdapters/V4FeeAdapter.sol
Comment thread src/feeAdapters/V4FeePolicy.sol Outdated
@ccashwell ccashwell marked this pull request as ready for review May 8, 2026 17:22
marktoda
marktoda previously approved these changes May 22, 2026
Comment thread src/feeAdapters/V4FeePolicy.sol Outdated
Comment thread src/feeAdapters/V4FeePolicy.sol
Comment thread src/feeAdapters/V4FeePolicy.sol
ccashwell added 17 commits May 28, 2026 11:41
…ok classification

Introduces a 2-contract architecture for Uniswap v4 protocol fees:

- V4FeeAdapter: protocolFeeController registered on PoolManager. Resolves
  fees via waterfall (pool override -> policy -> 0), triggers fee updates
  permissionlessly, and collects accrued fees to TokenJar.

- V4FeePolicy: replaceable policy brain with automated hook classification
  from address bits 0-3 (RETURNS_DELTA flags), a piecewise-constant baseline
  curve for static NativeMath pools, governance-assigned uint8 family IDs
  for custom-accounting/dynamic-fee hooks, per-family multipliers over pair
  fees, and a configurable default fee fallback. Hooks can self-report their
  family via IFeeClassifiedHook (gas-capped staticcall, governance override
  always wins).

- IFeeClassifiedHook: optional interface for hooks to self-report their
  protocol fee family without requiring a governance transaction.

Sentinel encoding (type(uint24).max) distinguishes explicit zero fees from
unset values. Each setter has a corresponding clear function for removing
config and falling through in the waterfall.

Includes 74 unit tests against a storage-layout-correct MockV4PoolManager
with extsload support, and 11 integration tests using the real v4 PoolManager
(via v4-core Deployers) with actual swaps that accrue and collect protocol
fees. Gas snapshots via vm.snapshotGasLastCall.

Also switches foundry.toml from pinned solc 0.8.29 to auto_detect_solc to
support v4-core's PoolManager (pragma solidity 0.8.26) in the same project.
…ld system

Hook self-classification now uses a uint256 bitfield of OR'd behavioral
flags (HookFeeFlags library) instead of a single uint8 familyId. Governance
configures priority-ordered flag rules that map flag patterns to families,
decoupling what a hook does from what fee it gets. Existing family-based
fee resolution (defaults, multipliers, pair fees) is unchanged.
Static-NativeMath pools now derive their protocol fee as a single global
pips multiplier of `key.fee` (denominator 1_000_000, capped at 100% of
LP fee, per-direction clamped to MAX_PROTOCOL_FEE). The `pairFees`
override is preserved as the higher-priority StaticNativeMath rule, and
the entire classified path (hookFamilyId, familyDefaults,
familyMultiplierBps, defaultFee, flag rules) is unchanged.

The new `_applyLpFeeMultiplier` helper is intentionally distinct from
the classified path's `_applyMultiplier` because the denominators
differ (1_000_000 pips vs 10_000 bps); reusing the latter would have
silently produced fees 100× too large.
`familyMultiplierBps` (denominator 10_000) becomes `familyMultiplierPips`
(denominator 1_000_000), sharing MULTIPLIER_DENOMINATOR with
`protocolFeeMultiplierPips`. Storage widens uint16 → uint24 to fit
1_000_000. The setter reuses MultiplierTooLarge to enforce the new
100% cap.

The clamp inside `_applyMultiplier` is removed: pairFees are validated
≤ MAX_PROTOCOL_FEE per direction at write time, and `multiplierPips` is
now bounded ≤ 1_000_000 by `setFamilyMultiplier`, so the product
divided by the denominator can never exceed MAX_PROTOCOL_FEE per
direction. The `_applyLpFeeMultiplier` clamp stays — `key.fee` is not
bounded by the policy and can drive the result above the cap.
Replaces the "TBD" V4 placeholder with a description of the
adapter+policy split, the StaticNativeMath/Classified path branching,
the LP-fee multiplier, family-based classification, and the
permissioned roles. Updates the project structure block to include
V4FeePolicy.sol, V3OpenFeeAdapter.sol, and V4FeeAdapter.fork.t.sol,
and removes Uniswap v4 from "future development" since it's no
longer future.
…kets

The StaticNativeMath path now resolves protocol fees via an
ascending-by-lpFeeFloor array of FeeBucket{lpFeeFloor, alphaPips,
betaPips} (max 16). For a given key.fee, the policy walks the array
backward to find the largest floor <= key.fee (snap to bucket 0 if
key.fee < floor_0) and returns alpha + beta * (key.fee - floor) /
1_000_000 per direction, clamped to MAX_PROTOCOL_FEE and packed
symmetrically. The pair-fee override still wins over the bucket
calculation.

Setting beta = 0 yields a step function; alpha = 0 yields a slope-only
multiplier; both nonzero yields a piecewise-linear curve. Continuity
at boundaries is governance's responsibility — the contract does not
enforce it. The lowest bucket's alpha doubles as a minimum-fee floor
for very-low-LP-fee pools, since key.fee < floor_0 evaluates as
delta = 0 against bucket 0.

Per-bucket validation: alpha <= MAX_PROTOCOL_FEE = 1000 (direct check,
since alpha is a single per-direction value rather than a packed two-
component fee), beta <= 1_000_000_000 (above which the per-direction
clamp always saturates). Reuses MultiplierTooLarge for beta and adds
EmptyBuckets / BucketsNotAscending / TooManyBuckets errors.
Ensure classified pair fee multipliers that truncate to zero use the family default while preserving explicit zero overrides.
Emit encoded fee storage values so indexers can distinguish explicit zero fees from cleared config.
Limit protocolFeeFlags returndata copying to one word so hooks cannot inflate caller gas with oversized return data.
Cache repeated reads and remove unused V4 policy imports to address low-risk audit cleanup items.
Update V4 adapter gas snapshots after the audit follow-up fixes and cleanup changes.
Use pairClassFees[pair][family] with NATIVE_MATH_FAMILY_ID for StaticNativeMath
overrides, literal classified fees per family, and remove family multipliers so
aggregator and native fee rollouts do not share a single pair-level knob.
Expose batchSetHookFamily, batchSetPairClassFee, and batchClearPairClassFee
so governance can configure many hooks or pairs in one transaction.
ccashwell added 2 commits May 28, 2026 11:41
Route all pools through _resolveFamily and a single computeFee waterfall.
Use NATIVE_MATH_FAMILY_ID (255) for static pools so unclassified (0) no longer
collides. Governance can opt static hooks into classified fees via setHookFamily.
Comment thread src/feeAdapters/V4FeePolicy.sol Outdated
ccashwell added 4 commits May 28, 2026 16:24
familyDefaults[255] was unreachable in computeFee; block writes at the
setter so governance cannot configure a dead slot. Pair class fees and
buckets remain the knobs for family 255.
…entions

The HookFeeFlags library shipped a speculative, non-exhaustive vocabulary
of behavioral bits onchain, but the policy ascribes no meaning to any bit:
_resolveFamily only checks that a rule's requiredFlags are a subset of the
hook's opaque uint256 self-report. Enumerating bits in a library implied a
contract-enforced semantics that does not exist (cf. the misleadingly-named
USES_DYNAMIC_FEE, where per-pool dynamic status is read authoritatively from
key.fee, not the hook's pool-agnostic report).

Move the flag vocabulary to the governance guide, where governance and hook
authors share one source of truth, and document the only convention in active
use (aggregator = bit 11). Test scaffolding keeps a local HookFeeFlags helper
since the matching tests need distinct named bits.

- Delete src/libraries/HookFeeFlags.sol
- Add test/utils/HookFeeFlags.sol (test-only fixture, same name)
- Update IFeeClassifiedHook / IV4FeePolicy comments to call the bitfield opaque
- Replace the governance guide's HookFeeFlags section with a conventions table
A freshly deployed V4FeePolicy returns zero for every pool until configured,
and triggerFeeUpdate is permissionless, so anyone can pin zero protocol fees
during the window between wiring a new policy into the adapter and configuring
it. Document that setPolicy must be the last step of an atomic deploy →
configure → setPolicy → trigger batch, since getFee keeps using the prior
policy until setPolicy lands. Also note the policy feeSetter is independent of
the adapter's and must be set by the owner before any config call.
Governance can force a pool to family 255 (native math) via setHookFamily or a
flag rule — a documented capability. For a dynamic-fee pool that path fed
key.fee (the 0x800000 DYNAMIC_FEE_FLAG sentinel, not an LP fee) into the bucket
curve, which silently clamped the protocol fee to MAX_PROTOCOL_FEE. The
auto-resolution gate in _resolveFamily already excludes dynamic pools, but the
forced paths bypassed it.

Guard the native-math branch in computeFee: dynamic-fee keys fall through to
defaultFee instead of bucket pricing. The pairClassFees[pair][255] override
check still runs first, so explicit governance intent for a dynamic pair is
honored.

Also reject familyId 0 in setPairClassFee / clearPairClassFee: computeFee
returns defaultFee for family 0 before the pairClassFees lookup, so a family-0
override was silent dead storage a fee-setter could mistake for active config.

Regression tests: dynamic pool forced to 255 -> defaultFee (not clamped);
pair override still wins; family-0 pair set/clear revert InvalidFamilyId.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants