feat: add V4 fee adapter with policy-based waterfall and automated classification#133
feat: add V4 fee adapter with policy-based waterfall and automated classification#133ccashwell wants to merge 26 commits into
Conversation
b904aaf to
42bb847
Compare
| /// 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) { |
There was a problem hiding this comment.
_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()
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
oh both contracts also have a feeSetter, and onlyFeeSetter. Maybe more stuff too? Is it worth having a base contract with common stuff?
There was a problem hiding this comment.
feels kind of weird that the 2 contracts might have different fee setter addresses? or is that intentional?
There was a problem hiding this comment.
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
…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.
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.
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.
Adds the V4 leg of the protocol-fee system: a long-lived
V4FeeAdapterregistered with v4-core's PoolManager as theprotocolFeeController, and a replaceableV4FeePolicythat 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 inpairClassFees[pairHash][familyId]so aggregator rollout and native-math rollout do not share a single pair-level knob. PermissionlesstriggerFeeUpdatepropagates resolved fees onto pools; permissionlesscollectroutes accrued protocol fees to the immutableTOKEN_JAR.Files
src/feeAdapters/V4FeeAdapter.solprotocolFeeController. Pool-level overrides, policy delegation, permissionlesstriggerFeeUpdate/batchTriggerFeeUpdate/collect.src/feeAdapters/V4FeePolicy.solsrc/interfaces/IV4FeeAdapter.sol,IV4FeePolicy.solFeeBucket,FlagRule).src/interfaces/IFeeClassifiedHook.solsrc/libraries/HookFeeFlags.soltest/V4FeeAdapter.t.solMockV4PoolManager.test/V4FeeAdapter.fork.t.solDeployers.snapshots/V4FeeAdapter*.jsonREADME.mdaudit/openzeppelin-4.pdfNet 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.
Slot0.protocolFeeis only set because someone previously calledadapter.triggerFeeUpdate(key), which calledPoolManager.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 fee0and stay there until triggered.Both
triggerFeeUpdateandcollectare permissionless because their effects are bounded by adapter/policy state (governance-controlled) and the immutableTOKEN_JARdestination.Fee resolution waterfall
A pool takes Path A when the hook has no
*_RETURNS_DELTAflags ANDkey.feeis static; Path B otherwise.key.feeis 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 inpairClassFeesfor StaticNativeMath pair overrides only. It is not the same ashookFamilyId == 0on Path B, which means unclassified and falls through todefaultFee. Path B never readspairClassFees[pair][0].Both paths use
MULTIPLIER_DENOMINATOR = 1_000_000for bucket slope math and the per-directionMAX_PROTOCOL_FEE = 1000clamp from v4-core'sProtocolFeeLibrary. v4-core re-validates the value pushed viasetProtocolFee(defense in depth).Path A — bucket schedule
Each
FeeBucketis(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.lpFee < floor_0snaps to bucket 0 withdelta = 0, soalpha_0doubles as a minimum-fee floor for very-low-LP-fee pools (race-to-the-bottom defense without an explicitminFeefield).MAX_BUCKETS); set atomically viasetFeeBuckets, cleared viaclearFeeBuckets.Optional Path A pair override:
setPairClassFee(c0, c1, NATIVE_MATH_FAMILY_ID, fee)— same behavior as the old flatpairFees, but scoped to the native-math slot so classified families cannot inherit it.Path B — family classification
_resolveFamily(hook):hookFamilyId[hook]wins if non-zero.hook.protocolFeeFlags(). Walk_flagRulesin array order; first rule whoserequiredFlagsare all set in the hook's flags wins.familyId = 0→defaultFeeonly.With
family > 0, the policy returnspairClassFees[ph][family]if set (literal fee, no scaling), elsefamilyDefaults[family], elsedefaultFee. 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 atfamily = 0unless pinned viasetHookFamily. Failure modes (revert / OOG / bad return data / no code) all gracefully fall through.Sentinel encoding
pairClassFees,familyDefaults,defaultFee, andpoolOverridesuseZERO_FEE_SENTINEL = type(uint24).maxto 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, andisValidProtocolFeerequires each ≤ 1000 — so there is no collision risk.Roles
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.setPairClassFee/clearPairClassFee), hook families, flag rules, family defaults,defaultFee.triggerFeeUpdate,batchTriggerFeeUpdate,collect, all view methods.Push-only fee setting
v4-core's
PoolManager.initializedoes not call into theprotocolFeeController(verified atlib/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 viatriggerFeeUpdate. Implications:Initialize,PolicyUpdated,FeeBucketsUpdated,PairClassFeeUpdated,PoolOverrideUpdated, etc., and triggers affected pools. Anyone can do this — it's a pure liveness role.PoolKeys on-chain; the keeper indexesPoolManager.Initializeevents to learn them.triggerFeeUpdatelands. 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:
Owned. Governance is trusted to handle ownership rotation correctly.pause(). Owner can effectively pause/disable fees viasetPolicy(address(0))+batchTriggerFeeUpdate(allPools).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.NATIVE_MATH_FAMILY_IDreuses uint80in a different namespace fromhookFamilyId == 0. Documented on-chain constant; Path B never indexes the native slot.TOKEN_JARis immutable, not validated at construction. Deployment-time concern; deploy script handles the invariant.batchTriggerFeeUpdateandcollectare unbounded. Caller bears their own gas.adapter.owner == policy.owner. Deploy script handles coordinated ownership transfer.setHookFamilydirectly.triggerFeeUpdateis permissionless even immediately after a config change. Bounded byMAX_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 currentV4FeePolicy; squash-merging is fine since this lands as one PR.