diff --git a/README.md b/README.md index 6983d378..0fc6cf67 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Uniswap Fee Collection -_A unified system for collecting and converting fees from arbitrary revenue sources on arbitrary chains._ +*A unified system for collecting and converting fees from arbitrary revenue sources on arbitrary chains.* ## Table of Contents @@ -110,10 +110,46 @@ Fee Sources are adapter contracts that channel fees from various protocols into - Permissionless protocol fee collection - Configurable fee rates per fee tier -**Uniswap V4 (TBD)** +**Uniswap V4** -- V4FeeAdapter as ProtocolFeeAdapter -- Not included as part of the initial fee enablement +- `V4FeeAdapter` registered with the PoolManager as `protocolFeeController`; permissionless `triggerFeeUpdate` and `collect` calls push resolved fees to the PoolManager and route accrued revenue to the TokenJar +- Fee resolution is split between a thin, long-lived adapter and a replaceable `V4FeePolicy`, so governance can iterate on fee strategy without re-handing PoolManager privileges +- Piecewise-linear protocol fee schedule for vanilla/static pools; family-based fees for classified hooks and dynamic-fee pools (see [V4 Fee Resolution](#v4-fee-resolution) and the [governance operator guide](docs/V4FeePolicy-governance-guide.md)) + +#### V4 Fee Resolution + +**Operator guide:** [docs/V4FeePolicy-governance-guide.md](docs/V4FeePolicy-governance-guide.md) — use cases, recipes, and footguns. + +The adapter checks for a per-pool override first, then delegates to the policy: + +``` +adapter.poolOverrides[poolId] ──► return (sentinel-decoded) + │ + └──► policy.computeFee(key) + │ + ├── resolve family (_resolveFamily) + │ hookFamilyId[hook] → else static pool → 255 (native math) + │ else protocolFeeFlags + flagRules → else 0 (unclassified) + │ + └── apply fee for family + 0 (unclassified) → defaultFee + pairClassFees[pair][family] (if set) + 255 (native math) → fee buckets from key.fee + 1–255 (classified) → familyDefaults[family] → defaultFee +``` + +**Native math (family 255):** pools with no `*_RETURNS_DELTA` hook bits and static LP fee (`key.fee != 0x800000`). Fee = `pairClassFees[pair][255]` if set, else piecewise-linear **fee buckets** (`alpha + beta × (lpFee - floor) / 1_000_000`, max 16 buckets). + +**Classified (families 1–255 via governance):** custom-accounting hooks, dynamic-fee pools, or any hook with `setHookFamily`. Waterfall: `pairClassFees` → `familyDefaults` → `defaultFee`. `key.fee` is not used for bucket math on these pools. + +**Unclassified (family 0):** `defaultFee` only (no buckets, no family defaults). + +Constants: `NATIVE_MATH_FAMILY_ID = 255`, `UNCLASSIFIED_FAMILY_ID = 0`. After policy or override changes, call `triggerFeeUpdate` so the PoolManager picks up new fees. + +Permissioned roles: + +- **owner** — swaps the policy, sets the fee-setter +- **feeSetter** — configures pool overrides, pair class fees, hook families, flag rules, family defaults, the fee buckets, and `defaultFee` ### 3. Releasers @@ -403,8 +439,10 @@ src/ │ ├── Nonce.sol // Utility contract to safely sequence multiple pending transactions │ └── ResourceManager.sol. // Utility contract for defining the `RESOURCE` token and its amount requirements ├── feeAdapters -│ ├── V3FeeAdapter.sol // Logic for Uniswap v3 fee-setting and collection -│ └── V4FeeAdapter.sol // Work-in-progress logic for Uniswap v4 fee-setting and collection +│ ├── V3FeeAdapter.sol // Logic for Uniswap v3 fee-setting and collection +│ ├── V3OpenFeeAdapter.sol // Permissionless v3 adapter for non-mainnet chains +│ ├── V4FeeAdapter.sol // V4 protocolFeeController: pool overrides + policy delegation +│ └── V4FeePolicy.sol // V4 fee resolution: LP-fee multiplier + family-based classification ├── interfaces/ // interfaces ├── libraries │ ├── ArrayLib.sol // Utility library @@ -422,6 +460,7 @@ test ├── ProtocolFees.fork.t.sol // Fork tests against Ethereum Mainnet, using Deployer.sol ├── V3FeeAdapter.t.sol ├── V4FeeAdapter.t.sol +├── V4FeeAdapter.fork.t.sol // V4 integration tests against a real PoolManager ├── interfaces/ // interfaces for integrations ├── mocks/ // mocks and examples └── utils @@ -456,7 +495,6 @@ Advanced mechanism design for optimizing fee collection efficiency through aucti ### Additional Protocol Support -- Uniswap v4 - UniswapX fee integration - Interface fee collection - Third-party protocol adapters diff --git a/audit/openzeppelin-4.pdf b/audit/openzeppelin-5.pdf similarity index 100% rename from audit/openzeppelin-4.pdf rename to audit/openzeppelin-5.pdf diff --git a/docs/V4FeePolicy-governance-guide.md b/docs/V4FeePolicy-governance-guide.md new file mode 100644 index 00000000..7aa6e3f4 --- /dev/null +++ b/docs/V4FeePolicy-governance-guide.md @@ -0,0 +1,336 @@ +# V4 Fee Policy — Governance Operator Guide + +This guide helps **fee setters** choose the right knob for a fee outcome on Uniswap V4. It reflects the unified `V4FeePolicy` resolution model (`NATIVE_MATH_FAMILY_ID = 255`, `UNCLASSIFIED_FAMILY_ID = 0`). + +**Contracts** + + +| Contract | Role | +| -------------- | ---------------------------------------------------------------------------------------------- | +| `V4FeeAdapter` | `protocolFeeController` on the PoolManager; per-pool overrides; `triggerFeeUpdate` / `collect` | +| `V4FeePolicy` | Replaceable fee logic; families, buckets, pair/family defaults | + + +**Roles** + + +| Role | Can do | +| ------------------------------ | -------------------------------------------------- | +| `feeSetter` (adapter + policy) | All fee configuration below | +| `owner` (adapter + policy) | Set `feeSetter`, swap `V4FeePolicy` on the adapter | + + +--- + +## End-to-end fee waterfall + +Every pool’s protocol fee is resolved in this order: + +``` +1. adapter.poolOverrides[poolId] → if set, use this (always wins) +2. policy.computeFee(poolKey) → see below +3. adapter returns 0 → if policy address is unset +``` + +Inside `**policy.computeFee**`: + +``` +1. family := _resolveFamily(poolKey) + +2. if family == 0 (unclassified) + → defaultFee + +3. if pairClassFees[pair][family] is set + → that fee (including explicit zero) + +4. if family == 255 (native math) + → fee buckets from key.fee (piecewise linear) + +5. if familyDefaults[family] is set + → family default + +6. → defaultFee +``` + +--- + +## How `family` is chosen (`_resolveFamily`) + + +| Step | Source | Result | +| ---- | -------------------------------------------------------------------- | ---------------------------------------------- | +| 1 | `hookFamilyId[hook] != 0` | Use governance assignment (families **1–255**) | +| 2 | Static pool: no return-delta hook bits **and** `key.fee != 0x800000` | **255** (native math) | +| 3 | `hook.protocolFeeFlags()` + `flagRules` (first match) | Rule’s `familyId` | +| 4 | Else | **0** (unclassified) | + + +**Static pool** = hook address has none of v4’s `*_RETURNS_DELTA` permission bits (low bits 0–3) and LP fee is not the dynamic-fee marker `0x800000`. + +**Custom-accounting hook** = any return-delta bit set in the hook address (baked in at deploy). These pools never auto-resolve to native math unless you assign a family in step 1. + +**Dynamic-fee pool** = `key.fee == 0x800000`. Live LP fee can change every swap; buckets are not used. Assign a family or rely on unclassified + `defaultFee`. + +Check onchain: `policy.isCustomAccounting(hook)` and `LPFeeLibrary.isDynamicFee(key.fee)`. + +--- + +## Pool types at a glance + + +| Pool shape | Default `family` | Fee mechanics | +| ------------------------------------------------ | ------------------------------ | --------------------------------------------- | +| Vanilla (`hooks = 0`, static LP fee) | **255** | `pairClassFees[pair][255]` or **fee buckets** | +| Static hook (e.g. `beforeSwap` only, static fee) | **255** unless `setHookFamily` | Same as vanilla | +| Custom-accounting or dynamic-fee, no assignment | **0** | `**defaultFee` only** | +| Any pool with `setHookFamily(hook, F)` | **F** | Classified waterfall (steps 3–6 above) | + + +--- + +## Use case → best mechanism + + +| Goal | Use | Contract | Notes | +| --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| **Protocol fee scales with LP fee tier** (0.05% / 0.3% / 1% pools) | `setFeeBuckets` | Policy | Default for vanilla + static-hook pools (`family` 255). Max 16 buckets, ascending `lpFeeFloor`. | +| **Different fee for one token pair** (all vanilla pools on that pair) | `setPairClassFee(c0, c1, 255, fee)` | Policy | `255` = `NATIVE_MATH_FAMILY_ID`. Overrides buckets for that pair only. | +| **Force one pool’s fee** (promo, migration, emergency) | `setPoolOverride(poolId, fee)` | Adapter | Highest priority; ignores policy. Use `0` for explicit zero. | +| **Zero fee on one pool** | `setPoolOverride(poolId, 0)` | Adapter | Simplest per-pool exempt. | +| **Flat fee for all pools on one hook** | `setHookFamily(hook, F)` + `setFamilyDefault(F, fee)` | Policy | Works for **static hooks** (opt out of buckets) and classified hooks. Use governance family **1–254** (`setFamilyDefault` rejects 255). | +| **Zero fee for all pools on one hook** | `setHookFamily(hook, F)` + `setFamilyDefault(F, 0)` | Policy | `0` encodes as explicit-zero sentinel. | +| **Zero fee for one pair on a hook family** | `setPairClassFee(c0, c1, F, 0)` | Policy | Does not fall through to family default. | +| **Per-pair fee within a hook family** | `setPairClassFee(c0, c1, F, fee)` | Policy | Finer than family default. | +| **Auto-group hooks by behavior** | `setFlagRules` + `setFamilyDefault` per family | Policy | Hook must implement `IFeeClassifiedHook.protocolFeeFlags()`. Rules sorted most-specific first (higher popcount). Max 32 rules. | +| **Override auto-classification for one hook** | `setHookFamily(hook, F)` | Policy | Beats flag rules. `setHookFamily(hook, 0)` clears assignment. | +| **Default for unclassified hooks / dynamic pools** | `setDefaultFee` | Policy | Used when `family == 0`. | +| **Remove a configured fee** | `clearFeeBuckets`, `clearFamilyDefault`, `clearPairClassFee`, `clearDefaultFee`, `clearPoolOverride` | Policy / Adapter | Prefer `clear`* over writing zero to “unset” bucket/default slots. | +| **Apply new policy logic chain-wide** | Deploy policy → `adapter.setPolicy` → `triggerFeeUpdate` / `batchTriggerFeeUpdate` | Adapter | PoolManager keeps old fee until retriggered. | + + +--- + +## Mechanism reference + +### Adapter (`V4FeeAdapter`) + + +| Function | Purpose | +| ------------------------------ | ------------------------------------------------- | +| `setPoolOverride(poolId, fee)` | Per-pool fee; wins over everything | +| `clearPoolOverride(poolId)` | Remove override | +| `triggerFeeUpdate(key)` | Push resolved fee to PoolManager (permissionless) | +| `batchTriggerFeeUpdate(keys)` | Batch retrigger | +| `collect(...)` | Pull accrued fees to TokenJar (permissionless) | + + +### Policy — native math (family 255) + + +| Function | Purpose | +| ----------------------------------- | ------------------------------------------------------------------------------------------------- | +| `setFeeBuckets(buckets)` | Global schedule: `alpha + beta × (lpFee - floor) / 1_000_000` per direction, clamped to 1000 pips | +| `clearFeeBuckets()` | Remove schedule (native math returns 0 if no pair override) | +| `setPairClassFee(c0, c1, 255, fee)` | Pair-specific override on native path | + + +Bucket tips: + +- `beta = 0` → flat `alpha` per tier. +- `alpha = 0` → pure multiplier on LP fee. +- Lowest bucket’s `alpha` is the floor when `lpFee < floor_0`. + +### Policy — classified families (1–255) + + +| Function | Purpose | +| ---------------------------------------- | --------------------------------------------------------- | +| `setHookFamily(hook, familyId)` | Manual family; `0` = clear | +| `batchSetHookFamily(assignments)` | Batch version | +| `setFamilyDefault(familyId, fee)` | Default for governance families **1–254** (rejects 0 and 255) | +| `setPairClassFee(c0, c1, familyId, fee)` | Pair + family slot | +| `setFlagRules(rules)` | Map `protocolFeeFlags()` → family | +| `clearFlagRules()` | Remove all rules | +| `setDefaultFee(fee)` | Unclassified + final fallback | + + +### Policy — constants (read-only) + + +| Constant | Value | Meaning | +| -------------------------- | ----- | --------------------------------------------- | +| `NATIVE_MATH_FAMILY_ID()` | `255` | Native math + `pairClassFees[pair][255]` slot | +| `UNCLASSIFIED_FAMILY_ID()` | `0` | No family; `defaultFee` only | + + +--- + +## Flag rules + +Hooks that implement `protocolFeeFlags()` return an opaque `uint256` bitfield. The policy +ascribes **no** meaning to any individual bit — per rule, it only checks that all of a +rule's `requiredFlags` bits are set in the hook's returned value. The meaning of each bit +is a convention agreed between governance (which writes the rules) and hooks (which set the +bits); it is **not** enumerated onchain. + +```solidity +// bit 11 (1 << 11) is the aggregator convention — see the table below +FlagRule({ requiredFlags: 1 << 11, familyId: 3 }) +``` + +- `requiredFlags` must be non-zero; all listed bits must be set on the hook's report. +- `familyId` must be **> 0** in rules (use family 3, not 0). +- Order rules from **most specific** to **least** (decreasing popcount of `requiredFlags`). + +Flag rules do **not** affect `family` by themselves — only the combination of `flagRules` +and the hook's returned bits does. + +### Bit conventions in active use + +| Bit | Value | Convention | Notes | +| --------- | ------ | --------------- | -------------------------------------------------- | +| `1 << 11` | `2048` | Aggregator hook | Only convention currently relied on in production. | + +Bits not listed here have no agreed meaning. Before publishing a rule against a new bit, +add it to this table so hook authors and governance share one source of truth. + +--- + +## Recipes (copy-paste patterns) + +### A. Turn on tiered fees for all vanilla pools + +```text +setFeeBuckets([...]) // policy +batchTriggerFeeUpdate(keys) // adapter — all target pools +``` + +### B. WETH/USDC pair pays 5 bps protocol fee everywhere (vanilla pools) + +```text +setPairClassFee(WETH, USDC, 255, fee) // sorted: currency0 < currency1 +triggerFeeUpdate(...) // per pool or batch +``` + +### C. Partner static-hook: flat 3 bps on all their pools + +```text +setHookFamily(hook, 7) +setFamilyDefault(7, fee_300) +batchTriggerFeeUpdate(partnerPoolKeys) +``` + +### D. Exempt partner static-hook entirely + +```text +setHookFamily(hook, 7) +setFamilyDefault(7, 0) // explicit zero +batchTriggerFeeUpdate(...) +``` + +### E. Custom-accounting hook: classify via flags + +```text +setFlagRules([{ STABLE_PAIR, 4 }, { AGGREGATOR, 5 }, ...]) +setFamilyDefault(4, fee_stable) +setFamilyDefault(5, fee_agg) +// hooks with protocolFeeFlags() pick up families automatically +``` + +### F. One pool only: zero fee (e.g. launch pool) + +```text +setPoolOverride(poolId, 0) // adapter — no policy family needed +triggerFeeUpdate(key) +``` + +### G. Dynamic-fee pool with governance family + +```text +setHookFamily(hook, 2) // or address(0) if no hook +setFamilyDefault(2, fee) +// optional: setPairClassFee for specific pairs +triggerFeeUpdate(key) +``` + +--- + +## Deploying or replacing a policy + +A freshly deployed `V4FeePolicy` is **empty**: the constructor only sets `POOL_MANAGER`. +`feeSetter`, `defaultFee`, the fee buckets, and the flag rules all start at zero, so +`computeFee` returns **0 for every pool** until governance has configured it. Because +`triggerFeeUpdate` is permissionless, anyone can push that zero into the PoolManager for +any pool during the window between wiring the policy in and configuring it. + +Avoid the window entirely by making `adapter.setPolicy(newPolicy)` the **last** step. +Until `setPolicy` lands, `adapter.getFee` keeps using the previous policy (or returns the +existing pool overrides), so an unconfigured new policy can do no harm while it is being +set up. Sequence the whole rollout as a single atomic governance batch: + +```text +1. deploy V4FeePolicy(poolManager) // owner = deployer +2. policy.setFeeSetter(govFeeSetter) // owner — required before any config call +3. policy.setDefaultFee / setFeeBuckets / // feeSetter — full configuration + setFlagRules / setHookFamily / ... +4. adapter.setPolicy(policy) // owner — flips reads to the new policy +5. adapter.batchTriggerFeeUpdate(keys) // push the new fees onchain +``` + +Steps 1–3 are observable but inert: no pool reads the new policy until step 4. If steps +4 and 5 cannot share one atomic batch with 1–3, at minimum keep 4 strictly after 3 — a +zero pushed in the gap is not permanent and is corrected by re-triggering the affected +pools, but a fully-configured policy at the moment of `setPolicy` avoids the issue +outright. + +> The new policy's `feeSetter` is independent of the adapter's and starts at zero. The +> policy `owner` (the deployer) must call `policy.setFeeSetter` (step 2) before any +> `onlyFeeSetter` configuration call will succeed. + +--- + +## Footguns + +1. **Config ≠ live fee** — Changing policy or overrides does not update PoolManager until `triggerFeeUpdate` (anyone can call). +2. **Family 255 and `familyDefaults`** — Native math uses buckets and `pairClassFees[pair][255]`. `setFamilyDefault(255, …)` and `clearFamilyDefault(255)` **revert** (`InvalidFamilyId`). `setHookFamily(hook, 255)` is still allowed to force the native-math branch on classified pools. +3. **Unclassified ≠ native math** — `family == 0` uses only `defaultFee`. It never reads fee buckets, even if buckets are configured. +4. **Explicit zero vs unset** — `setFamilyDefault(F, 0)` and `setPairClassFee(..., 0)` mean **zero fee**. To remove config, use `clearFamilyDefault` / `clearPairClassFee`. +5. **Pair ordering** — `setPairClassFee` requires `currency0 < currency1` (sorted addresses). +6. **Dynamic LP fee** — Do not rely on `key.fee` for amount on dynamic pools; assign a family and use family/pair defaults. +7. **Return-delta hooks** — Address bits 0–3 force classified path unless you `setHookFamily`. `beforeSwap`-only hooks (bit 7, etc.) stay on native math by default. +8. **Policy swap** — `adapter.setPolicy(newPolicy)` requires retrigger on all pools; consider batch. A freshly deployed policy returns **zero everywhere** until configured, and `triggerFeeUpdate` is permissionless — configure fully *before* `setPolicy` (see [Deploying or replacing a policy](#deploying-or-replacing-a-policy)). + +--- + +## Quick decision tree + +```text +Need to set fee for ONE pool only? + YES → adapter.setPoolOverride + NO ↓ + +Pool vanilla (no hook / static hook, static LP fee)? + YES → fee buckets and/or pairClassFees[..., 255] + NO ↓ + +Know the hook address and want same fee on all its pools? + YES → setHookFamily + setFamilyDefault (or pairClassFees) + NO ↓ + +Want hooks to self-select by behavior? + YES → setFlagRules + setFamilyDefaults + NO ↓ + +Fallback for odd hooks / dynamic fee / no rules match? + → setDefaultFee +``` + +--- + +## Related code + +- `src/feeAdapters/V4FeePolicy.sol` — `computeFee`, `_resolveFamily` +- `src/feeAdapters/V4FeeAdapter.sol` — `getFee`, overrides +- `src/interfaces/IFeeClassifiedHook.sol` — hook self-report interface +- `test/V4FeeAdapter.t.sol` — unit tests (search `Unified resolution regression`) + diff --git a/foundry.toml b/foundry.toml index 2b71bf7c..a285e9bd 100644 --- a/foundry.toml +++ b/foundry.toml @@ -2,7 +2,7 @@ evm_version = "cancun" optimizer = true optimizer_runs = 10_000_000 -solc_version = "0.8.29" +auto_detect_solc = true verbosity = 3 gas_limit = "9223372036854775807" no_match_test = "test_fuzz_gas_release_malicious" diff --git a/remappings.txt b/remappings.txt index 97dde7f0..4f0f371f 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1,4 +1,5 @@ solmate/=lib/solmate/ +solady/=lib/optimism/packages/contracts-bedrock/lib/solady/ @eth-optimism-bedrock/=lib/optimism/packages/contracts-bedrock eas-contracts/=lib/dao-signer/lib/eas-contracts/contracts/ scripts/libraries/Config.sol=lib/optimism/packages/contracts-bedrock/scripts/libraries/Config.sol diff --git a/snapshots/V4FeeAdapterForkTest.json b/snapshots/V4FeeAdapterForkTest.json new file mode 100644 index 00000000..0cf5f805 --- /dev/null +++ b/snapshots/V4FeeAdapterForkTest.json @@ -0,0 +1,6 @@ +{ + "fork: batchTriggerFeeUpdate 3 pools": "99022", + "fork: collect 2 currencies": "99567", + "fork: collect single currency": "62947", + "fork: triggerFeeUpdate single pool": "58325" +} \ No newline at end of file diff --git a/snapshots/V4FeeAdapterTest.json b/snapshots/V4FeeAdapterTest.json new file mode 100644 index 00000000..ba1b535f --- /dev/null +++ b/snapshots/V4FeeAdapterTest.json @@ -0,0 +1,26 @@ +{ + "adapter.batchTriggerFeeUpdate - two pools": "80861", + "adapter.collect - single currency": "58078", + "adapter.getFee - pool override hit": "3330", + "adapter.setPoolOverride": "48093", + "adapter.triggerFeeUpdate - single pool": "58269", + "policy.batchClearPairClassFee": "33148", + "policy.batchSetHookFamily": "72791", + "policy.batchSetPairClassFee": "76610", + "policy.computeFee - classified family default": "7928", + "policy.computeFee - classified flag-rule self-report": "17486", + "policy.computeFee - classified griefing hook -> defaultFee": "7723", + "policy.computeFee - classified pair class fee": "5694", + "policy.computeFee - classified unclassified -> defaultFee": "7723", + "policy.computeFee - flag-rule multi-flag match": "17486", + "policy.computeFee - flag-rule single flag match": "17486", + "policy.computeFee - static native math buckets": "11351", + "policy.computeFee - static native math pair class fee": "5938", + "policy.setFamilyDefault": "47850", + "policy.setFeeBuckets - 1 bucket": "71511", + "policy.setFeeBuckets - 5 buckets (configuration B)": "148719", + "policy.setFlagRules - 32 rules (max)": "1499897", + "policy.setFlagRules - two rules": "138137", + "policy.setHookFamily": "47601", + "policy.setPairClassFee": "49476" +} \ No newline at end of file diff --git a/src/feeAdapters/V4FeeAdapter.sol b/src/feeAdapters/V4FeeAdapter.sol new file mode 100644 index 00000000..f7b3aa58 --- /dev/null +++ b/src/feeAdapters/V4FeeAdapter.sol @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Owned} from "solmate/src/auth/Owned.sol"; +import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; +import {PoolKey} from "v4-core/types/PoolKey.sol"; +import {PoolId, PoolIdLibrary} from "v4-core/types/PoolId.sol"; +import {ProtocolFeeLibrary} from "v4-core/libraries/ProtocolFeeLibrary.sol"; +import {StateLibrary} from "v4-core/libraries/StateLibrary.sol"; +import {IV4FeeAdapter} from "../interfaces/IV4FeeAdapter.sol"; +import {IV4FeePolicy} from "../interfaces/IV4FeePolicy.sol"; + +/// @title V4FeeAdapter +/// @notice The protocolFeeController for the Uniswap V4 PoolManager. Resolves fees via a +/// waterfall (pool override → policy → 0), pushes them to the PoolManager, and collects +/// accrued fees to the TokenJar. +/// @dev The adapter is the trusted, long-lived piece. The policy is replaceable by the owner. +/// @custom:security-contact security@uniswap.org +contract V4FeeAdapter is IV4FeeAdapter, Owned { + using PoolIdLibrary for PoolKey; + using StateLibrary for IPoolManager; + + /// @dev Sentinel value: stored to represent an explicit zero fee. type(uint24).max is + /// safe because each 12-bit component (0xFFF = 4095) exceeds MAX_PROTOCOL_FEE (1000). + uint24 public constant ZERO_FEE_SENTINEL = type(uint24).max; + + /// @inheritdoc IV4FeeAdapter + IPoolManager public immutable POOL_MANAGER; + + /// @inheritdoc IV4FeeAdapter + address public immutable TOKEN_JAR; + + /// @inheritdoc IV4FeeAdapter + address public feeSetter; + + /// @inheritdoc IV4FeeAdapter + IV4FeePolicy public policy; + + /// @inheritdoc IV4FeeAdapter + mapping(PoolId poolId => uint24) public poolOverrides; + + /// @notice Restricts access to the fee setter address. + modifier onlyFeeSetter() { + if (msg.sender != feeSetter) revert Unauthorized(); + _; + } + + /// @notice Constructs the V4FeeAdapter with immutable references to the PoolManager and + /// TokenJar. The deployer becomes the initial owner. + /// @param poolManager The Uniswap V4 PoolManager this adapter is the protocolFeeController + /// for. Must be registered via PoolManager.setProtocolFeeController() after deployment. + /// @param tokenJar The address where all collected protocol fees are sent. + constructor(IPoolManager poolManager, address tokenJar) Owned(msg.sender) { + POOL_MANAGER = poolManager; + TOKEN_JAR = tokenJar; + } + + // ─── Fee Resolution ─── + + /// @inheritdoc IV4FeeAdapter + function getFee(PoolKey memory key) public view returns (uint24) { + uint24 stored = poolOverrides[key.toId()]; + if (stored != 0) return _decodeFee(stored); + IV4FeePolicy currentPolicy = policy; + if (address(currentPolicy) == address(0)) return 0; + return currentPolicy.computeFee(key); + } + + // ─── Permissionless Triggering ─── + + /// @inheritdoc IV4FeeAdapter + function triggerFeeUpdate(PoolKey calldata key) external { + _setProtocolFee(key); + } + + /// @inheritdoc IV4FeeAdapter + function batchTriggerFeeUpdate(PoolKey[] calldata keys) external { + uint256 length = keys.length; + for (uint256 i; i < length; ++i) { + _setProtocolFee(keys[i]); + } + } + + // ─── Collection ─── + + /// @inheritdoc IV4FeeAdapter + function collect(CollectParams[] calldata params) external { + uint256 length = params.length; + for (uint256 i; i < length; ++i) { + CollectParams calldata p = params[i]; + uint256 collected = POOL_MANAGER.collectProtocolFees(TOKEN_JAR, p.currency, p.amount); + emit FeesCollected(p.currency, collected); + } + } + + // ─── Admin (onlyOwner) ─── + + /// @inheritdoc IV4FeeAdapter + function setPolicy(IV4FeePolicy newPolicy) external onlyOwner { + emit PolicyUpdated(address(policy), address(newPolicy)); + policy = newPolicy; + } + + /// @inheritdoc IV4FeeAdapter + function setFeeSetter(address newFeeSetter) external onlyOwner { + if (newFeeSetter == address(0)) revert ZeroAddress(); + emit FeeSetterUpdated(feeSetter, newFeeSetter); + feeSetter = newFeeSetter; + } + + // ─── Pool Overrides (onlyFeeSetter) ─── + + /// @inheritdoc IV4FeeAdapter + function setPoolOverride(PoolId poolId, uint24 feeValue) external onlyFeeSetter { + if (feeValue != 0) _validateFee(feeValue); + uint24 stored = _encodeFee(feeValue); + poolOverrides[poolId] = stored; + emit PoolOverrideUpdated(poolId, stored); + } + + /// @inheritdoc IV4FeeAdapter + function clearPoolOverride(PoolId poolId) external onlyFeeSetter { + delete poolOverrides[poolId]; + emit PoolOverrideUpdated(poolId, 0); + } + + // ─── Internal ─── + + /// @dev Resolves the fee for a pool via the waterfall, checks that the pool is + /// initialized, and pushes the fee to the PoolManager. Silently skips uninitialized + /// pools (sqrtPriceX96 == 0) to avoid a revert from the PoolManager and save gas. + /// @param key The pool key identifying the pool to update. + function _setProtocolFee(PoolKey memory key) internal { + PoolId id = key.toId(); + + // Check pool is initialized (sqrtPriceX96 != 0) before calling PoolManager + (uint160 sqrtPriceX96,,,) = POOL_MANAGER.getSlot0(id); + if (sqrtPriceX96 == 0) return; + + uint24 feeValue = getFee(key); + POOL_MANAGER.setProtocolFee(key, feeValue); + emit FeeUpdateTriggered(msg.sender, id, feeValue); + } + + /// @dev Encodes a fee for storage. Converts 0 to ZERO_FEE_SENTINEL so that 0 in + /// storage means "not set" rather than "explicitly zero". + /// @param feeValue The actual fee value (0 = explicit zero). + /// @return The encoded value to store. + function _encodeFee(uint24 feeValue) internal pure returns (uint24) { + return feeValue == 0 ? ZERO_FEE_SENTINEL : feeValue; + } + + /// @dev Decodes a fee from storage. Converts ZERO_FEE_SENTINEL back to 0. + /// @param stored The raw value from storage. + /// @return The actual fee value. + function _decodeFee(uint24 stored) internal pure returns (uint24) { + return stored == ZERO_FEE_SENTINEL ? 0 : stored; + } + + /// @dev Validates that a protocol fee is within v4-core bounds (each 12-bit directional + /// component must be <= MAX_PROTOCOL_FEE = 1000). + /// @param feeValue The fee to validate. + function _validateFee(uint24 feeValue) internal pure { + if (!ProtocolFeeLibrary.isValidProtocolFee(feeValue)) revert InvalidFeeValue(); + } +} diff --git a/src/feeAdapters/V4FeePolicy.sol b/src/feeAdapters/V4FeePolicy.sol new file mode 100644 index 00000000..8edf735b --- /dev/null +++ b/src/feeAdapters/V4FeePolicy.sol @@ -0,0 +1,429 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Owned} from "solmate/src/auth/Owned.sol"; +import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; +import {PoolKey} from "v4-core/types/PoolKey.sol"; +import {Currency} from "v4-core/types/Currency.sol"; +import {LPFeeLibrary} from "v4-core/libraries/LPFeeLibrary.sol"; +import {ProtocolFeeLibrary} from "v4-core/libraries/ProtocolFeeLibrary.sol"; +import {LibBit} from "solady/src/utils/LibBit.sol"; +import { + IV4FeePolicy, + FlagRule, + FeeBucket, + HookFamilyAssignment, + PairClassFeeAssignment, + PairClassFeeClear +} from "../interfaces/IV4FeePolicy.sol"; +import {IFeeClassifiedHook} from "../interfaces/IFeeClassifiedHook.sol"; + +/// @title V4FeePolicy +/// @notice Computes protocol fees for Uniswap V4 pools using automated hook classification +/// and a piecewise-linear schedule of fee buckets. +/// @dev Fee resolution: resolve a family ID, then apply family-specific logic. +/// - `NATIVE_MATH_FAMILY_ID` (255) on static pools (no RETURNS_DELTA, static LP fee): +/// `pairClassFees[pair][255]` or the fee-bucket schedule. +/// - Governance families (1-254): `pairClassFees[pair][family]` → `familyDefaults[family]` +/// → `defaultFee`. Family 255 uses buckets only (`familyDefaults[255]` is rejected). +/// - Unclassified custom-accounting / dynamic-fee pools (`family == 0`): `defaultFee` only. +/// Family resolution: governance `hookFamilyId` → static pools default to native math → +/// hook `protocolFeeFlags()` matched against flag rules → unclassified (0). +/// @custom:security-contact security@uniswap.org +contract V4FeePolicy is IV4FeePolicy, Owned { + using LPFeeLibrary for uint24; + + /// @inheritdoc IV4FeePolicy + uint8 public constant NATIVE_MATH_FAMILY_ID = 0xFF; + + /// @inheritdoc IV4FeePolicy + uint8 public constant UNCLASSIFIED_FAMILY_ID = 0; + + /// @dev Bitmask for the four RETURNS_DELTA flags (bits 0-3 of hook address). + uint160 public constant CUSTOM_ACCOUNTING_MASK = 0xF; + + /// @dev Gas limit for hook self-report calls. Prevents griefing in batch operations. + uint256 internal constant SELF_REPORT_GAS_LIMIT = 30_000; + + /// @dev Sentinel value: stored to represent an explicit zero fee. type(uint24).max is + /// safe because each 12-bit component (0xFFF = 4095) exceeds MAX_PROTOCOL_FEE (1000). + uint24 internal constant ZERO_FEE_SENTINEL = type(uint24).max; + + /// @dev Shared denominator for pips-based bucket slopes. 1_000_000 = 100% (matches the + /// v4-core LP fee denominator). + uint24 internal constant MULTIPLIER_DENOMINATOR = LPFeeLibrary.MAX_LP_FEE; + + /// @dev Maximum number of fee buckets. Bounds the backward walk in + /// `_computeStaticNativeMathFee`. + uint256 internal constant MAX_BUCKETS = 16; + + /// @dev Maximum `betaPips` per bucket. Above this, even a 1-pip delta exceeds + /// MAX_PROTOCOL_FEE, so the per-direction clamp is always hit and values above are + /// functionally identical noise. + uint32 internal constant MAX_BETA_PIPS = + uint32(uint256(MULTIPLIER_DENOMINATOR) * ProtocolFeeLibrary.MAX_PROTOCOL_FEE); + + /// @inheritdoc IV4FeePolicy + IPoolManager public immutable POOL_MANAGER; + + /// @inheritdoc IV4FeePolicy + address public feeSetter; + + /// @inheritdoc IV4FeePolicy + uint24 public defaultFee; + + /// @inheritdoc IV4FeePolicy + mapping(address hook => uint8) public hookFamilyId; + + /// @inheritdoc IV4FeePolicy + mapping(uint8 familyId => uint24) public familyDefaults; + + /// @inheritdoc IV4FeePolicy + mapping(bytes32 pairHash => mapping(uint8 familyId => uint24)) public pairClassFees; + + /// @dev Ordered fee buckets for native-math pools. Ascending by `lpFeeFloor`. Set + /// atomically via `setFeeBuckets`. Empty array → native math returns 0 when no pair + /// class fee override exists. + FeeBucket[] internal _feeBuckets; + + /// @dev Maximum number of flag rules to bound gas in _resolveFamily. + uint256 internal constant MAX_FLAG_RULES = 32; + + /// @dev Ordered flag rules for mapping self-reported hook flags to family IDs. + /// First matching rule wins. Set atomically via setFlagRules(). + FlagRule[] internal _flagRules; + + /// @notice Restricts access to the fee setter address. + modifier onlyFeeSetter() { + if (msg.sender != feeSetter) revert Unauthorized(); + _; + } + + /// @notice Constructs the V4FeePolicy with a reference to the PoolManager. + /// @param poolManager The Uniswap V4 PoolManager this policy reads state from. + constructor(IPoolManager poolManager) Owned(msg.sender) { + POOL_MANAGER = poolManager; + } + + // ─── Pure Classification ─── + + /// @inheritdoc IV4FeePolicy + function isCustomAccounting(address hook) external pure returns (bool) { + return _isCustomAccounting(hook); + } + + // ─── Fee Computation ─── + + /// @inheritdoc IV4FeePolicy + function computeFee(PoolKey calldata key) external view returns (uint24) { + bytes32 ph = _pairHash(key.currency0, key.currency1); + uint8 family = _resolveFamily(key); + + // Unclassified: use defaultFee. + if (family == UNCLASSIFIED_FAMILY_ID) return _decodeFee(defaultFee); + + // Explicitly set family: check for pairClassFees first. + uint24 stored = pairClassFees[ph][family]; + if (stored != 0) return _decodeFee(stored); + + // Native math: use fee buckets. Dynamic-fee keys carry the 0x800000 sentinel, not an + // LP fee, so they cannot be priced from buckets — fall through to defaultFee. A dynamic + // pool only reaches family 255 when governance forces it (setHookFamily / a flag rule); + // the auto-resolution gate in _resolveFamily already excludes dynamic pools. + if (family == NATIVE_MATH_FAMILY_ID) { + if (key.fee.isDynamicFee()) return _decodeFee(defaultFee); + return _computeStaticNativeMathFee(key.fee); + } + + // Fall through to familyDefaults. + uint24 famDefault = familyDefaults[family]; + if (famDefault != 0) return _decodeFee(famDefault); + + // Fall through to defaultFee. + return _decodeFee(defaultFee); + } + + // ─── Flag Rules Getters ─── + + /// @inheritdoc IV4FeePolicy + function flagRulesLength() external view returns (uint256) { + return _flagRules.length; + } + + /// @inheritdoc IV4FeePolicy + function flagRules(uint256 index) external view returns (uint256 requiredFlags, uint8 familyId) { + FlagRule storage rule = _flagRules[index]; + return (rule.requiredFlags, rule.familyId); + } + + // ─── Fee Bucket Getters ─── + + /// @inheritdoc IV4FeePolicy + function feeBucketsLength() external view returns (uint256) { + return _feeBuckets.length; + } + + /// @inheritdoc IV4FeePolicy + function feeBucket(uint256 index) + external + view + returns (uint24 lpFeeFloor, uint24 alphaPips, uint32 betaPips) + { + FeeBucket storage b = _feeBuckets[index]; + return (b.lpFeeFloor, b.alphaPips, b.betaPips); + } + + // ─── Admin ─── + + /// @inheritdoc IV4FeePolicy + function setFeeSetter(address newFeeSetter) external onlyOwner { + emit FeeSetterUpdated(feeSetter, newFeeSetter); + feeSetter = newFeeSetter; + } + + // ─── Configuration (onlyFeeSetter) ─── + + /// @inheritdoc IV4FeePolicy + function setHookFamily(address hook, uint8 familyId) external onlyFeeSetter { + _setHookFamily(hook, familyId); + } + + /// @inheritdoc IV4FeePolicy + function batchSetHookFamily(HookFamilyAssignment[] calldata assignments) external onlyFeeSetter { + uint256 len = assignments.length; + for (uint256 i; i < len; ++i) { + HookFamilyAssignment calldata a = assignments[i]; + _setHookFamily(a.hook, a.familyId); + } + } + + /// @inheritdoc IV4FeePolicy + function setFlagRules(FlagRule[] calldata rules) external onlyFeeSetter { + if (rules.length > MAX_FLAG_RULES) revert TooManyFlagRules(); + + delete _flagRules; + + uint256 previousSpecificity = type(uint256).max; + for (uint256 i; i < rules.length; ++i) { + FlagRule calldata rule = rules[i]; + if (rule.requiredFlags == 0 || rule.familyId == 0) revert InvalidFlagRule(); + uint256 specificity = LibBit.popCount(rule.requiredFlags); + if (specificity > previousSpecificity) revert FlagRulesNotSorted(); + previousSpecificity = specificity; + _flagRules.push(rule); + } + + emit FlagRulesUpdated(rules.length); + } + + /// @inheritdoc IV4FeePolicy + function clearFlagRules() external onlyFeeSetter { + delete _flagRules; + emit FlagRulesUpdated(0); + } + + /// @inheritdoc IV4FeePolicy + function setDefaultFee(uint24 feeValue) external onlyFeeSetter { + if (feeValue != 0) _validateFee(feeValue); + uint24 stored = _encodeFee(feeValue); + defaultFee = stored; + emit DefaultFeeUpdated(stored); + } + + /// @inheritdoc IV4FeePolicy + function clearDefaultFee() external onlyFeeSetter { + delete defaultFee; + emit DefaultFeeUpdated(0); + } + + /// @inheritdoc IV4FeePolicy + function setFeeBuckets(FeeBucket[] calldata buckets) external onlyFeeSetter { + uint256 len = buckets.length; + if (len == 0) revert EmptyBuckets(); + if (len > MAX_BUCKETS) revert TooManyBuckets(); + + delete _feeBuckets; + + uint24 prevFloor; + for (uint256 i; i < len; ++i) { + FeeBucket calldata b = buckets[i]; + if (i > 0 && b.lpFeeFloor <= prevFloor) revert BucketsNotAscending(); + if (b.alphaPips > ProtocolFeeLibrary.MAX_PROTOCOL_FEE) revert InvalidFeeValue(); + if (b.betaPips > MAX_BETA_PIPS) revert MultiplierTooLarge(); + _feeBuckets.push(b); + prevFloor = b.lpFeeFloor; + } + + emit FeeBucketsUpdated(len); + } + + /// @inheritdoc IV4FeePolicy + function clearFeeBuckets() external onlyFeeSetter { + delete _feeBuckets; + emit FeeBucketsUpdated(0); + } + + /// @inheritdoc IV4FeePolicy + function setFamilyDefault(uint8 familyId, uint24 feeValue) external onlyFeeSetter { + if (familyId == 0 || familyId == NATIVE_MATH_FAMILY_ID) revert InvalidFamilyId(); + if (feeValue != 0) _validateFee(feeValue); + uint24 stored = _encodeFee(feeValue); + familyDefaults[familyId] = stored; + emit FamilyDefaultUpdated(familyId, stored); + } + + /// @inheritdoc IV4FeePolicy + function clearFamilyDefault(uint8 familyId) external onlyFeeSetter { + if (familyId == 0 || familyId == NATIVE_MATH_FAMILY_ID) revert InvalidFamilyId(); + delete familyDefaults[familyId]; + emit FamilyDefaultUpdated(familyId, 0); + } + + /// @inheritdoc IV4FeePolicy + function setPairClassFee(Currency currency0, Currency currency1, uint8 familyId, uint24 feeValue) + external + onlyFeeSetter + { + _setPairClassFee(currency0, currency1, familyId, feeValue); + } + + /// @inheritdoc IV4FeePolicy + function batchSetPairClassFee(PairClassFeeAssignment[] calldata assignments) + external + onlyFeeSetter + { + uint256 len = assignments.length; + for (uint256 i; i < len; ++i) { + PairClassFeeAssignment calldata a = assignments[i]; + _setPairClassFee(a.currency0, a.currency1, a.familyId, a.feeValue); + } + } + + /// @inheritdoc IV4FeePolicy + function clearPairClassFee(Currency currency0, Currency currency1, uint8 familyId) + external + onlyFeeSetter + { + _clearPairClassFee(currency0, currency1, familyId); + } + + /// @inheritdoc IV4FeePolicy + function batchClearPairClassFee(PairClassFeeClear[] calldata clears) external onlyFeeSetter { + uint256 len = clears.length; + for (uint256 i; i < len; ++i) { + PairClassFeeClear calldata c = clears[i]; + _clearPairClassFee(c.currency0, c.currency1, c.familyId); + } + } + + // ─── Internal ─── + + function _setHookFamily(address hook, uint8 familyId) internal { + hookFamilyId[hook] = familyId; + emit HookFamilySet(hook, familyId); + } + + function _setPairClassFee(Currency currency0, Currency currency1, uint8 familyId, uint24 feeValue) + internal + { + // family 0 is never read by computeFee (it returns defaultFee before the pairClassFees + // lookup), so a family-0 override would be silent dead storage. + if (familyId == UNCLASSIFIED_FAMILY_ID) revert InvalidFamilyId(); + if (currency0 >= currency1) revert CurrenciesOutOfOrder(); + if (feeValue != 0) _validateFee(feeValue); + bytes32 ph = _pairHash(currency0, currency1); + uint24 stored = _encodeFee(feeValue); + pairClassFees[ph][familyId] = stored; + emit PairClassFeeUpdated(ph, familyId, stored); + } + + function _clearPairClassFee(Currency currency0, Currency currency1, uint8 familyId) internal { + if (familyId == UNCLASSIFIED_FAMILY_ID) revert InvalidFamilyId(); + if (currency0 >= currency1) revert CurrenciesOutOfOrder(); + bytes32 ph = _pairHash(currency0, currency1); + delete pairClassFees[ph][familyId]; + emit PairClassFeeUpdated(ph, familyId, 0); + } + + /// @dev Returns true if the hook address has any RETURNS_DELTA flag set (bits 0-3). + function _isCustomAccounting(address hook) internal pure returns (bool) { + return uint160(hook) & CUSTOM_ACCOUNTING_MASK != 0; + } + + /// @dev Resolves the family ID for a pool: governance override, native math default for + /// static pools, then flag rules, then unclassified (0). + function _resolveFamily(PoolKey calldata key) internal view returns (uint8) { + address hook = address(key.hooks); + uint8 gov = hookFamilyId[hook]; + if (gov != 0) return gov; + + if (!_isCustomAccounting(hook) && !key.fee.isDynamicFee()) return NATIVE_MATH_FAMILY_ID; + + uint256 rulesLen = _flagRules.length; + if (rulesLen == 0) return 0; + + uint256 flags; + bool ok; + uint256 gasLimit = SELF_REPORT_GAS_LIMIT; + uint256 selector = uint32(IFeeClassifiedHook.protocolFeeFlags.selector); + assembly ("memory-safe") { + let ptr := mload(0x40) + mstore(ptr, shl(224, selector)) + ok := staticcall(gasLimit, hook, ptr, 0x04, ptr, 0x20) + ok := and(ok, iszero(lt(returndatasize(), 0x20))) + flags := mload(ptr) + } + if (ok && flags != 0) { + for (uint256 i; i < rulesLen; ++i) { + FlagRule storage rule = _flagRules[i]; + uint256 requiredFlags = rule.requiredFlags; + if (flags & requiredFlags == requiredFlags) return rule.familyId; + } + } + + return 0; + } + + /// @dev Canonical hash for a sorted token pair. + function _pairHash(Currency c0, Currency c1) internal pure returns (bytes32) { + return keccak256(abi.encodePacked(Currency.unwrap(c0), Currency.unwrap(c1))); + } + + /// @dev Piecewise-linear fee from `_feeBuckets` for StaticNativeMath pools. + function _computeStaticNativeMathFee(uint24 lpFee) internal view returns (uint24) { + uint256 len = _feeBuckets.length; + if (len == 0) return 0; + + FeeBucket memory bucket = _feeBuckets[0]; + for (uint256 i = len; i > 1; --i) { + FeeBucket memory candidate = _feeBuckets[i - 1]; + if (candidate.lpFeeFloor <= lpFee) { + bucket = candidate; + break; + } + } + + uint256 delta = lpFee >= bucket.lpFeeFloor ? lpFee - bucket.lpFeeFloor : 0; + uint256 perDirection = + uint256(bucket.alphaPips) + uint256(bucket.betaPips) * delta / MULTIPLIER_DENOMINATOR; + if (perDirection > ProtocolFeeLibrary.MAX_PROTOCOL_FEE) { + perDirection = ProtocolFeeLibrary.MAX_PROTOCOL_FEE; + } + return uint24((perDirection << 12) | perDirection); + } + + /// @dev Encodes a fee for storage. Converts 0 to ZERO_FEE_SENTINEL. + function _encodeFee(uint24 feeValue) internal pure returns (uint24) { + return feeValue == 0 ? ZERO_FEE_SENTINEL : feeValue; + } + + /// @dev Decodes a fee from storage. + function _decodeFee(uint24 stored) internal pure returns (uint24) { + return stored == ZERO_FEE_SENTINEL ? 0 : stored; + } + + /// @dev Validates protocol fee bounds. + function _validateFee(uint24 feeValue) internal pure { + if (!ProtocolFeeLibrary.isValidProtocolFee(feeValue)) revert InvalidFeeValue(); + } +} diff --git a/src/interfaces/IFeeClassifiedHook.sol b/src/interfaces/IFeeClassifiedHook.sol new file mode 100644 index 00000000..58cffd00 --- /dev/null +++ b/src/interfaces/IFeeClassifiedHook.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.26; + +/// @title IFeeClassifiedHook +/// @notice Optional interface for v4 hooks to self-report behavioral flags for +/// protocol fee classification. +/// @dev Hooks return an opaque uint256 bitfield. The V4FeePolicy ascribes no meaning +/// to individual bits — it only matches the returned value against governance-configured +/// flag rules to derive a family ID. Gas-capped staticcall prevents griefing. +/// Governance can always override via setHookFamily(). See the governance guide for the +/// bit conventions in active use. +/// @custom:security-contact security@uniswap.org +interface IFeeClassifiedHook { + /// @notice Returns the hook's self-reported behavioral flags. + /// @dev Return 0 to indicate no self-classification (falls through to defaultFee). + /// The bitfield is opaque to the policy; see the governance guide for active conventions. + function protocolFeeFlags() external view returns (uint256); +} diff --git a/src/interfaces/IV4FeeAdapter.sol b/src/interfaces/IV4FeeAdapter.sol new file mode 100644 index 00000000..7813a22d --- /dev/null +++ b/src/interfaces/IV4FeeAdapter.sol @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; +import {PoolKey} from "v4-core/types/PoolKey.sol"; +import {PoolId} from "v4-core/types/PoolId.sol"; +import {Currency} from "v4-core/types/Currency.sol"; +import {IV4FeePolicy} from "./IV4FeePolicy.sol"; + +/// @title IV4FeeAdapter +/// @notice Interface for the V4 fee adapter — the protocolFeeController registered on the +/// PoolManager. Resolves fees via pool overrides or policy delegation, triggers fee updates, +/// and collects accrued fees to the TokenJar. +/// @custom:security-contact security@uniswap.org +interface IV4FeeAdapter { + // --- Errors --- + + /// @notice Thrown when an unauthorized address calls a restricted function. + error Unauthorized(); + + /// @notice Thrown when a zero address is provided. + error ZeroAddress(); + + /// @notice Thrown when a fee value fails ProtocolFeeLibrary.isValidProtocolFee. + error InvalidFeeValue(); + + // --- Events --- + + /// @notice Emitted when the policy contract is updated. + /// @param oldPolicy The previous policy address. + /// @param newPolicy The new policy address. + event PolicyUpdated(address indexed oldPolicy, address indexed newPolicy); + + /// @notice Emitted when a pool override is set or removed. + /// @dev `feeValue` is the encoded storage value: 0 = removed/unset, + /// ZERO_FEE_SENTINEL = explicit zero fee. + /// @param poolId The pool whose override changed. + /// @param feeValue The new encoded fee value. + event PoolOverrideUpdated(PoolId indexed poolId, uint24 feeValue); + + /// @notice Emitted when the fee setter address is updated. + /// @param oldFeeSetter The previous fee setter address. + /// @param newFeeSetter The new fee setter address. + event FeeSetterUpdated(address indexed oldFeeSetter, address indexed newFeeSetter); + + /// @notice Emitted when a fee update is triggered for a pool. + /// @param caller The address that triggered the update. + /// @param poolId The pool that was updated. + /// @param feeValue The protocol fee that was set on the PoolManager. + event FeeUpdateTriggered(address indexed caller, PoolId indexed poolId, uint24 feeValue); + + /// @notice Emitted when protocol fees are collected. + /// @param currency The currency that was collected. + /// @param amount The amount collected. + event FeesCollected(Currency indexed currency, uint256 amount); + + // --- Structs --- + + /// @notice Parameters for collecting protocol fees. + struct CollectParams { + /// @dev The currency to collect. + Currency currency; + /// @dev The amount to collect. 0 = collect all accrued. + uint256 amount; + } + + // --- Immutables --- + + /// @notice The Uniswap V4 PoolManager this adapter controls fees for. + /// @return The PoolManager contract. + function POOL_MANAGER() external view returns (IPoolManager); + + /// @notice The address where collected fees are sent. + /// @return The TokenJar address. + function TOKEN_JAR() external view returns (address); + + /// @notice Sentinel value representing an explicit zero fee in storage. + /// @dev type(uint24).max — safe because isValidProtocolFee rejects it. + /// @return The sentinel value (0xFFFFFF). + function ZERO_FEE_SENTINEL() external pure returns (uint24); + + // --- State --- + + /// @notice The address authorized to set pool overrides. + /// @return The current fee setter address. + function feeSetter() external view returns (address); + + /// @notice The current fee policy contract. + /// @return The policy contract address. + function policy() external view returns (IV4FeePolicy); + + /// @notice Returns the pool-specific fee override. + /// @param poolId The pool to query. + /// @return The sentinel-encoded fee override. 0 = not set. + function poolOverrides(PoolId poolId) external view returns (uint24); + + // --- Admin (onlyOwner) --- + + /// @notice Sets the fee policy contract. Only callable by owner. + /// @dev Setting address(0) disables policy — all non-overridden pools get fee 0. + /// @param newPolicy The new policy contract address. + function setPolicy(IV4FeePolicy newPolicy) external; + + /// @notice Sets the fee setter address. Only callable by owner. + /// @param newFeeSetter The new fee setter address. + function setFeeSetter(address newFeeSetter) external; + + // --- Pool Overrides (onlyFeeSetter) --- + + /// @notice Sets a pool-specific fee override (highest priority in waterfall). + /// @dev Setting 0 sets an explicit zero fee (does NOT fall through to policy). + /// Use clearPoolOverride to remove the override entirely. + /// @param poolId The pool to override. + /// @param feeValue The protocol fee to set. Must pass isValidProtocolFee if non-zero. + function setPoolOverride(PoolId poolId, uint24 feeValue) external; + + /// @notice Removes a pool-specific fee override, falling through to policy. + /// @param poolId The pool to clear the override for. + function clearPoolOverride(PoolId poolId) external; + + // --- Fee Resolution --- + + /// @notice Resolves the protocol fee for a pool: pool override → policy → 0. + /// @param key The pool key to resolve the fee for. + /// @return fee The resolved protocol fee. + function getFee(PoolKey memory key) external view returns (uint24 fee); + + // --- Permissionless Triggering --- + + /// @notice Triggers a fee update for a single pool. Permissionless. + /// @dev Silently skips uninitialized pools (sqrtPriceX96 == 0). + /// @param key The pool key to update. + function triggerFeeUpdate(PoolKey calldata key) external; + + /// @notice Triggers fee updates for multiple pools. Permissionless. + /// @dev Silently skips uninitialized pools. + /// @param keys The pool keys to update. + function batchTriggerFeeUpdate(PoolKey[] calldata keys) external; + + // --- Collection --- + + /// @notice Collects protocol fees to the TOKEN_JAR. Permissionless. + /// @dev Safe because funds always go to the immutable TOKEN_JAR. The PoolManager + /// enforces that only the protocolFeeController (this contract) can call + /// collectProtocolFees. + /// @param params Array of currencies and amounts to collect. + function collect(CollectParams[] calldata params) external; +} diff --git a/src/interfaces/IV4FeePolicy.sol b/src/interfaces/IV4FeePolicy.sol new file mode 100644 index 00000000..c23b6f3a --- /dev/null +++ b/src/interfaces/IV4FeePolicy.sol @@ -0,0 +1,342 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; +import {PoolKey} from "v4-core/types/PoolKey.sol"; +import {Currency} from "v4-core/types/Currency.sol"; + +/// @dev A single segment of the piecewise-linear protocol-fee schedule on the +/// StaticNativeMath path. Buckets are stored in an ascending-by-lpFeeFloor array. +/// For a given `key.fee`, evaluation finds the bucket with the largest floor +/// <= key.fee (or bucket 0 if key.fee < floor_0) and returns +/// alpha + beta * (key.fee - floor) / 1_000_000 per direction (clamped to +/// MAX_PROTOCOL_FEE). +struct FeeBucket { + /// @dev LP-fee floor for this bucket, in pips. Ascending across the array; matches v4-core fee + /// type. + uint24 lpFeeFloor; + /// @dev Flat base fee per direction in pips. Must be <= MAX_PROTOCOL_FEE (1000). + uint24 alphaPips; + /// @dev Slope: pips of protocol fee per pip of (lpFee - floor). Capped to 1_000_000_000 + /// (above which any 1-pip delta saturates the per-direction MAX_PROTOCOL_FEE clamp). + uint32 betaPips; +} + +/// @dev A flag-to-family mapping rule. The policy walks rules in order; the first rule +/// whose requiredFlags are all present in the hook's self-reported flags wins. +struct FlagRule { + /// @dev Bitmask of flags that must ALL be set in the hook's protocolFeeFlags() return + /// value for this rule to match. The bits are an opaque convention between governance + /// and hooks; see the governance guide for the values in active use. + uint256 requiredFlags; + /// @dev The family ID assigned when this rule matches. Must be > 0. + uint8 familyId; +} + +/// @dev Hook address and family for `batchSetHookFamily`. +struct HookFamilyAssignment { + address hook; + uint8 familyId; +} + +/// @dev Token pair, family slot, and fee for `batchSetPairClassFee`. +struct PairClassFeeAssignment { + Currency currency0; + Currency currency1; + uint8 familyId; + uint24 feeValue; +} + +/// @dev Token pair and family slot for `batchClearPairClassFee`. +struct PairClassFeeClear { + Currency currency0; + Currency currency1; + uint8 familyId; +} + +/// @title IV4FeePolicy +/// @notice Interface for the V4 fee policy contract that computes protocol fees based on +/// automated hook classification and governance-configured parameters. +/// @dev Governance hook family IDs are 1-255. 0 = unclassified. `NATIVE_MATH_FAMILY_ID` +/// (255) is also used internally for native-math resolution on static pools; governance may +/// reuse 255 for classified config when appropriate. +/// Hooks can self-report behavioral flags via IFeeClassifiedHook.protocolFeeFlags(); +/// governance-configured flag rules map flag patterns to families automatically. +/// Static NativeMath pools bypass hook classification and derive their protocol fee from +/// `pairClassFees[pair][NATIVE_MATH_FAMILY_ID]` or a piecewise-linear fee-bucket schedule. +/// Custom-accounting hooks and dynamic fee pools require classification (governance +/// override, flag rule match, or defaultFee fallback). +/// @custom:security-contact security@uniswap.org +interface IV4FeePolicy { + // --- Errors --- + + /// @notice Thrown when an unauthorized address calls a restricted function. + error Unauthorized(); + + /// @notice Thrown when a fee value fails ProtocolFeeLibrary.isValidProtocolFee. + error InvalidFeeValue(); + + /// @notice Thrown when familyId is 0 or `NATIVE_MATH_FAMILY_ID` on family-default setters. + error InvalidFamilyId(); + + /// @notice Thrown when a fee bucket's `betaPips` exceeds 1_000_000_000. + error MultiplierTooLarge(); + + /// @notice Thrown when currency0 >= currency1 in setPairClassFee. + error CurrenciesOutOfOrder(); + + /// @notice Thrown when a flag rule has requiredFlags == 0 or a non-governance familyId. + error InvalidFlagRule(); + + /// @notice Thrown when flag rules exceed the maximum allowed count. + error TooManyFlagRules(); + + /// @notice Thrown when flag rules are not sorted from most to least specific. + error FlagRulesNotSorted(); + + /// @notice Thrown when setFeeBuckets is called with an empty array. + error EmptyBuckets(); + + /// @notice Thrown when fee buckets are not in strictly ascending order of lpFeeFloor. + error BucketsNotAscending(); + + /// @notice Thrown when fee buckets exceed the maximum allowed count. + error TooManyBuckets(); + + // --- Events --- + + /// @notice Emitted when the fee setter address is updated. + /// @param oldFeeSetter The previous fee setter address. + /// @param newFeeSetter The new fee setter address. + event FeeSetterUpdated(address indexed oldFeeSetter, address indexed newFeeSetter); + + /// @notice Emitted when a hook's family classification is set or cleared. + /// @param hook The hook address that was classified. + /// @param familyId The assigned family ID (0 = unclassified). + event HookFamilySet(address indexed hook, uint8 familyId); + + /// @notice Emitted when a family's default protocol fee is updated. + /// @dev `feeValue` is the encoded storage value: 0 = removed/unset, + /// ZERO_FEE_SENTINEL = explicit zero fee. + /// @param familyId The family whose default was changed. + /// @param feeValue The new encoded default fee. + event FamilyDefaultUpdated(uint8 indexed familyId, uint24 feeValue); + + /// @notice Emitted when a pair class fee is updated. + /// @dev `feeValue` is the encoded storage value: 0 = removed/unset, + /// ZERO_FEE_SENTINEL = explicit zero fee. `familyId` is `NATIVE_MATH_FAMILY_ID` for + /// native-math pair overrides, or a governance family ID (1-255) otherwise. + /// @param pairHash The canonical hash of the token pair. + /// @param familyId The family slot that was changed. + /// @param feeValue The new encoded fee. + event PairClassFeeUpdated(bytes32 indexed pairHash, uint8 indexed familyId, uint24 feeValue); + + /// @notice Emitted when the fee buckets array is replaced. + /// @param bucketCount The number of buckets in the new array. + event FeeBucketsUpdated(uint256 bucketCount); + + /// @notice Emitted when the default classified fee is updated. + /// @dev `feeValue` is the encoded storage value: 0 = removed/unset, + /// ZERO_FEE_SENTINEL = explicit zero fee. + /// @param feeValue The new encoded default fee. + event DefaultFeeUpdated(uint24 feeValue); + + /// @notice Emitted when the flag rules array is replaced. + /// @param ruleCount The number of rules in the new array. + event FlagRulesUpdated(uint256 ruleCount); + + // --- Constants --- + + /// @notice Bitmask for the four RETURNS_DELTA flags (bits 0-3 of hook address). + /// @dev BEFORE_SWAP_RETURNS_DELTA (bit 3) | AFTER_SWAP_RETURNS_DELTA (bit 2) | + /// AFTER_ADD_LIQUIDITY_RETURNS_DELTA (bit 1) | AFTER_REMOVE_LIQUIDITY_RETURNS_DELTA (bit 0) + /// @return The bitmask value (0xF). + function CUSTOM_ACCOUNTING_MASK() external pure returns (uint160); + + /// @notice Reserved `pairClassFees` family slot for native-math pair overrides. + /// @dev Not assignable as a governance hook family. Distinct from `hookFamilyId == 0`. + /// @return 0xFF (255 == `type(uint8).max`). + function NATIVE_MATH_FAMILY_ID() external pure returns (uint8); + + /// @notice Reserved `pairClassFees` family slot for unclassified hooks. + /// @dev Not assignable as a governance hook family. Distinct from `NATIVE_MATH_FAMILY_ID`. + /// @return 0 (unclassified). + function UNCLASSIFIED_FAMILY_ID() external pure returns (uint8); + + // --- Immutables --- + + /// @notice The Uniswap V4 PoolManager this policy reads state from. + /// @return The PoolManager contract. + function POOL_MANAGER() external view returns (IPoolManager); + + // --- State --- + + /// @notice The address authorized to configure fees. + /// @return The current fee setter address. + function feeSetter() external view returns (address); + + /// @notice Fallback fee for classified pools when no family-specific config applies. + /// @dev Also used for unclassified hooks (familyId == 0). Sentinel-encoded in storage. + /// @return The sentinel-encoded default fee. + function defaultFee() external view returns (uint24); + + /// @notice Returns the governance-assigned family ID for a hook. + /// @dev 0 = unclassified on the classified path. StaticNativeMath pools do not use this. + /// @param hook The hook address to query. + /// @return The family ID (0-255). + function hookFamilyId(address hook) external view returns (uint8); + + /// @notice Returns the default protocol fee for a given family ID. + /// @param familyId The family to query (must be > 0 when set via admin). + /// @return The sentinel-encoded default fee for the family. + function familyDefaults(uint8 familyId) external view returns (uint24); + + /// @notice Returns the pair class fee for a token pair and family slot. + /// @dev `familyId == NATIVE_MATH_FAMILY_ID` is the StaticNativeMath pair override. + /// Governance families use IDs 1-255; `NATIVE_MATH_FAMILY_ID` is the native-math slot. + /// 0 in storage = not set. + /// @param pairHash The canonical keccak256 hash of the sorted token pair. + /// @param familyId The family slot to query. + /// @return The sentinel-encoded fee (0 = not set). + function pairClassFees(bytes32 pairHash, uint8 familyId) external view returns (uint24); + + /// @notice Returns the number of fee buckets configured. + /// @return The count of fee buckets. + function feeBucketsLength() external view returns (uint256); + + /// @notice Returns the fee bucket at the given index. + /// @param index The zero-based index into the buckets array. + /// @return lpFeeFloor The LP-fee floor for this bucket. + /// @return alphaPips The flat base fee per direction in pips. + /// @return betaPips The slope: pips of protocol fee per pip of (lpFee - floor). + function feeBucket(uint256 index) + external + view + returns (uint24 lpFeeFloor, uint24 alphaPips, uint32 betaPips); + + /// @notice Returns the number of flag rules configured. + /// @return The count of flag rules. + function flagRulesLength() external view returns (uint256); + + /// @notice Returns the flag rule at the given index. + /// @param index The zero-based index into the rules array. + /// @return requiredFlags The flags that must all be present for a match. + /// @return familyId The family ID assigned on match. + function flagRules(uint256 index) external view returns (uint256 requiredFlags, uint8 familyId); + + // --- Pure Classification --- + + /// @notice Returns true if the hook has any RETURNS_DELTA flag set (bits 0-3). + /// @dev Pure function of the hook address — no storage reads, no external calls. + /// @param hook The hook address to check. + /// @return True if the hook performs custom accounting. + function isCustomAccounting(address hook) external pure returns (bool); + + // --- Fee Computation --- + + /// @notice Computes the protocol fee for a pool. + /// @dev Resolve family, then: native math (`NATIVE_MATH_FAMILY_ID` on static pools) → + /// `pairClassFees[pair][family]` → `familyDefaults[family]` → `defaultFee`. + /// Unclassified custom-accounting / dynamic-fee pools (`family == 0`) → `defaultFee` only. + /// Callable by anyone (no access control) for offchain tooling. + /// @param key The pool key to compute the fee for. + /// @return fee The computed protocol fee (two 12-bit directional components packed). + function computeFee(PoolKey calldata key) external view returns (uint24 fee); + + // --- Admin (onlyOwner) --- + + /// @notice Sets the fee setter address. Only callable by owner. + /// @param newFeeSetter The new fee setter address. + function setFeeSetter(address newFeeSetter) external; + + // --- Classification (onlyFeeSetter) --- + + /// @notice Assign a hook to a governance-defined family. + /// @dev familyId 0 unclassifies the hook. Overwrites any existing classification. + /// @param hook The hook address to classify. + /// @param familyId The family ID to assign (0 = unclassify). + function setHookFamily(address hook, uint8 familyId) external; + + /// @notice Assigns multiple hooks to families in one transaction. + /// @param assignments Hook/family pairs. Emits `HookFamilySet` per entry. + function batchSetHookFamily(HookFamilyAssignment[] calldata assignments) external; + + // --- Flag Rules (onlyFeeSetter) --- + + /// @notice Replaces the entire flag rules array atomically. + /// @dev Rules are checked in order; the first rule whose requiredFlags are all present + /// in the hook's self-reported flags wins. Rules must be sorted by non-increasing + /// number of set bits in requiredFlags, so more specific patterns come first. Each + /// rule must have requiredFlags != 0 and familyId > 0. Max 32 rules. + /// @param rules The new flag rules, ordered by match priority (first match wins). + function setFlagRules(FlagRule[] calldata rules) external; + + /// @notice Removes all flag rules. + function clearFlagRules() external; + + // --- Default Fee (onlyFeeSetter) --- + + /// @notice Sets the fallback fee for classified pools (including unclassified hooks). + /// @dev Setting 0 sets an explicit zero fee. Use clearDefaultFee to remove entirely. + /// @param feeValue The protocol fee to set. Must pass isValidProtocolFee if non-zero. + function setDefaultFee(uint24 feeValue) external; + + /// @notice Removes the default fee, so unclassified pools return 0. + function clearDefaultFee() external; + + // --- Fee Bucket Configuration (onlyFeeSetter) --- + + /// @notice Replaces the entire fee-buckets array atomically. + /// @dev Must be non-empty, ascending by `lpFeeFloor` (strict), and at most 16 buckets. + /// Each bucket: `alphaPips <= MAX_PROTOCOL_FEE` (1000), `betaPips <= 1_000_000_000`. + /// The lowest bucket's `alpha` acts as a minimum-fee floor for very-low-LP-fee pools + /// because of the snap-to-lowest behavior in `computeFee`. + /// Reverts: `EmptyBuckets`, `TooManyBuckets`, `BucketsNotAscending`, + /// `InvalidFeeValue` (alpha out of range), `MultiplierTooLarge` (beta out of range). + /// @param buckets The new fee buckets, ordered ascending by lpFeeFloor. + function setFeeBuckets(FeeBucket[] calldata buckets) external; + + /// @notice Removes all fee buckets. StaticNativeMath returns 0 when no pair class fee is set. + function clearFeeBuckets() external; + + // --- Family Defaults (onlyFeeSetter) --- + + /// @notice Sets the default protocol fee for a governance family (1-254). + /// @dev Reverts for 0 and `NATIVE_MATH_FAMILY_ID` (use pair class fees / buckets for 255). + /// Setting fee 0 sets explicit zero. Use clearFamilyDefault to remove entirely. + /// @param familyId The family to configure. + /// @param feeValue The default fee. Must pass isValidProtocolFee if non-zero. + function setFamilyDefault(uint8 familyId, uint24 feeValue) external; + + /// @notice Removes the default fee for a governance family (1-254). + /// @dev Reverts for 0 and `NATIVE_MATH_FAMILY_ID`. + /// @param familyId The family to clear. + function clearFamilyDefault(uint8 familyId) external; + + // --- Pair Class Fees (onlyFeeSetter) --- + + /// @notice Sets the pair class fee for a token pair and family slot. + /// @dev Use `NATIVE_MATH_FAMILY_ID` for native-math pair overrides, or 1-255 for + /// governance families. Setting fee 0 sets explicit zero and does not fall through. + /// Use clearPairClassFee to remove entirely. + /// @param currency0 The lower currency of the pair (must be < currency1). + /// @param currency1 The higher currency of the pair. + /// @param familyId The family slot (`NATIVE_MATH_FAMILY_ID` or 1-255). + /// @param feeValue The protocol fee. Must pass isValidProtocolFee if non-zero. + function setPairClassFee(Currency currency0, Currency currency1, uint8 familyId, uint24 feeValue) + external; + + /// @notice Sets multiple pair class fees in one transaction. + /// @param assignments Pair/family/fee tuples. Emits `PairClassFeeUpdated` per entry. + function batchSetPairClassFee(PairClassFeeAssignment[] calldata assignments) external; + + /// @notice Removes the pair class fee for a family slot. + /// @param currency0 The lower currency of the pair (must be < currency1). + /// @param currency1 The higher currency of the pair. + /// @param familyId The family slot to clear. + function clearPairClassFee(Currency currency0, Currency currency1, uint8 familyId) external; + + /// @notice Clears multiple pair class fees in one transaction. + /// @param clears Pair/family slots to remove. Emits `PairClassFeeUpdated` with fee 0 per entry. + function batchClearPairClassFee(PairClassFeeClear[] calldata clears) external; +} diff --git a/test/V4FeeAdapter.fork.t.sol b/test/V4FeeAdapter.fork.t.sol new file mode 100644 index 00000000..c4b3fcdc --- /dev/null +++ b/test/V4FeeAdapter.fork.t.sol @@ -0,0 +1,450 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.26; + +import "forge-std/Test.sol"; +import {Deployers} from "../lib/v4-core/test/utils/Deployers.sol"; +import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol"; +import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; +import {PoolKey} from "v4-core/types/PoolKey.sol"; +import {PoolId, PoolIdLibrary} from "v4-core/types/PoolId.sol"; +import {Currency, CurrencyLibrary} from "v4-core/types/Currency.sol"; +import {IHooks} from "v4-core/interfaces/IHooks.sol"; +import {PoolSwapTest} from "v4-core/test/PoolSwapTest.sol"; +import {TickMath} from "v4-core/libraries/TickMath.sol"; +import {LPFeeLibrary} from "v4-core/libraries/LPFeeLibrary.sol"; +import {ProtocolFeeLibrary} from "v4-core/libraries/ProtocolFeeLibrary.sol"; +import {StateLibrary} from "v4-core/libraries/StateLibrary.sol"; +import {ModifyLiquidityParams, SwapParams} from "v4-core/types/PoolOperation.sol"; +import {BalanceDelta} from "v4-core/types/BalanceDelta.sol"; + +import {V4FeeAdapter, IV4FeeAdapter} from "../src/feeAdapters/V4FeeAdapter.sol"; +import {V4FeePolicy, IV4FeePolicy} from "../src/feeAdapters/V4FeePolicy.sol"; +import {FeeBucket} from "../src/interfaces/IV4FeePolicy.sol"; + +/// @notice Integration tests using a real v4 PoolManager (deployed locally via Deployers). +/// Verifies protocol fee accrual from real swaps, collection to TokenJar, and the full +/// adapter + policy waterfall against live pool state. +contract V4FeeAdapterForkTest is Deployers { + using PoolIdLibrary for PoolKey; + using StateLibrary for IPoolManager; + using CurrencyLibrary for Currency; + + V4FeeAdapter adapter; + V4FeePolicy policy; + address tokenJar; + + address owner; + address feeSetter; + + PoolKey pool500; // 5 bps LP fee + PoolKey pool3000; // 30 bps LP fee + PoolKey pool10000; // 100 bps LP fee + + uint24 constant PROTO_FEE_100 = (100 << 12) | 100; + uint24 constant PROTO_FEE_200 = (200 << 12) | 200; + uint24 constant PROTO_FEE_300 = (300 << 12) | 300; + uint24 constant PROTO_FEE_500 = (500 << 12) | 500; + + function setUp() public { + owner = address(this); + feeSetter = makeAddr("feeSetter"); + + // Deploy real v4 PoolManager + routers + deployFreshManagerAndRouters(); + deployMintAndApprove2Currencies(); + + // Use a plain address as the fee destination (avoids TokenJar pragma conflict) + tokenJar = makeAddr("tokenJar"); + + // Deploy adapter + policy + policy = new V4FeePolicy(manager); + adapter = new V4FeeAdapter(manager, tokenJar); + adapter.setPolicy(policy); + adapter.setFeeSetter(feeSetter); + policy.setFeeSetter(feeSetter); + + // Register adapter as the protocolFeeController on the real PoolManager + manager.setProtocolFeeController(address(adapter)); + + // Initialize pools with liquidity at different fee tiers. + // Use explicit tick spacings and aligned tick ranges for each. + (pool3000,) = initPool(currency0, currency1, IHooks(address(0)), 3000, 60, SQRT_PRICE_1_1); + (pool500,) = initPool(currency0, currency1, IHooks(address(0)), 500, 10, SQRT_PRICE_1_1); + (pool10000,) = initPool(currency0, currency1, IHooks(address(0)), 10_000, 200, SQRT_PRICE_1_1); + + // Add liquidity with tick ranges aligned to each pool's tick spacing + modifyLiquidityRouter.modifyLiquidity( + pool3000, + ModifyLiquidityParams({tickLower: -120, tickUpper: 120, liquidityDelta: 100e18, salt: 0}), + ZERO_BYTES + ); + modifyLiquidityRouter.modifyLiquidity( + pool500, + ModifyLiquidityParams({tickLower: -100, tickUpper: 100, liquidityDelta: 100e18, salt: 0}), + ZERO_BYTES + ); + modifyLiquidityRouter.modifyLiquidity( + pool10000, + ModifyLiquidityParams({tickLower: -200, tickUpper: 200, liquidityDelta: 100e18, salt: 0}), + ZERO_BYTES + ); + } + + /// @dev Equivalent of the pre-bucket-era global multiplier: a single bucket starting + /// at floor 0 with `betaPips = X`. Result: `protocolFee = X * lpFee / 1_000_000`. + function _singleBucketSlope(uint32 betaPips) internal pure returns (FeeBucket[] memory bs) { + bs = new FeeBucket[](1); + bs[0] = FeeBucket({lpFeeFloor: 0, alphaPips: 0, betaPips: betaPips}); + } + + // ============ Unified resolution regression (deployed policy) ============ + + function test_policy_familyIdConstants() public view { + assertEq(policy.NATIVE_MATH_FAMILY_ID(), 0xFF); + assertEq(policy.UNCLASSIFIED_FAMILY_ID(), 0); + } + + function test_policy_dynamicFee_governancePairClassFee() public { + PoolKey memory dynamicPool = PoolKey({ + currency0: currency0, + currency1: currency1, + fee: LPFeeLibrary.DYNAMIC_FEE_FLAG, + tickSpacing: 60, + hooks: IHooks(address(0)) + }); + + vm.startPrank(feeSetter); + policy.setHookFamily(address(0), 2); + policy.setPairClassFee(currency0, currency1, 2, PROTO_FEE_100); + vm.stopPrank(); + + assertEq(policy.computeFee(dynamicPool), PROTO_FEE_100); + } + + function test_policy_staticPool_governanceOptIn_classifiedFamilyDefault() public { + PoolKey memory staticHookPool = PoolKey({ + currency0: currency0, + currency1: currency1, + fee: 3000, + tickSpacing: 60, + hooks: IHooks(address(uint160(1 << 7))) + }); + + vm.startPrank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(100_000)); + policy.setHookFamily(address(staticHookPool.hooks), 1); + policy.setFamilyDefault(1, PROTO_FEE_200); + vm.stopPrank(); + + assertEq(policy.computeFee(staticHookPool), PROTO_FEE_200); + } + + function test_fork_governanceStaticHook_optIn_triggersClassifiedFeeOnManager() public { + IHooks hook = IHooks(address(uint160(1 << 7))); + PoolKey memory staticHookPool; + (staticHookPool,) = initPool(currency0, currency1, hook, 3000, 60, SQRT_PRICE_1_1); + modifyLiquidityRouter.modifyLiquidity( + staticHookPool, + ModifyLiquidityParams({tickLower: -120, tickUpper: 120, liquidityDelta: 10e18, salt: 0}), + ZERO_BYTES + ); + + vm.startPrank(feeSetter); + policy.setHookFamily(address(staticHookPool.hooks), 1); + policy.setFamilyDefault(1, PROTO_FEE_200); + vm.stopPrank(); + + adapter.triggerFeeUpdate(staticHookPool); + + (,, uint24 fee,) = manager.getSlot0(staticHookPool.toId()); + assertEq(fee, PROTO_FEE_200); + } + + // ============ End-to-End: Set Fee -> Swap -> Accrue -> Collect ============ + + function test_e2e_setFee_swap_collect() public { + // 66_667 pips × pool3000.fee (3000) / 1_000_000 = 200 per direction (= PROTO_FEE_200) + vm.prank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(66_667)); + + // Trigger fee update on the 3000 bps pool + adapter.triggerFeeUpdate(pool3000); + vm.snapshotGasLastCall("fork: triggerFeeUpdate single pool"); + + // Verify protocol fee was set on the PoolManager + (,, uint24 protocolFee,) = manager.getSlot0(pool3000.toId()); + assertEq(protocolFee, PROTO_FEE_200); + + // Execute a swap (oneForZero, exact input) + int256 swapAmount = -1e18; + SwapParams memory params = SwapParams({ + zeroForOne: false, amountSpecified: swapAmount, sqrtPriceLimitX96: MAX_PRICE_LIMIT + }); + BalanceDelta delta = + swapRouter.swap(pool3000, params, PoolSwapTest.TestSettings(false, false), ZERO_BYTES); + + // Protocol fees should have accrued on currency1 (the input) + uint256 expectedFee = + uint256(uint128(-delta.amount1())) * 200 / ProtocolFeeLibrary.PIPS_DENOMINATOR; + uint256 accrued = manager.protocolFeesAccrued(currency1); + assertEq(accrued, expectedFee); + assertTrue(accrued > 0, "No fees accrued"); + + // Collect to TokenJar + IV4FeeAdapter.CollectParams[] memory collectParams = new IV4FeeAdapter.CollectParams[](1); + collectParams[0] = IV4FeeAdapter.CollectParams({currency: currency1, amount: 0}); + adapter.collect(collectParams); + vm.snapshotGasLastCall("fork: collect single currency"); + + // Verify fees landed in TokenJar + assertEq(MockERC20(Currency.unwrap(currency1)).balanceOf(tokenJar), accrued); + + // Verify accrued is now 0 + assertEq(manager.protocolFeesAccrued(currency1), 0); + } + + // ============ Multiplier: Pools Scale Linearly With LP Fee ============ + + function test_buckets_differentPoolsLinearlyScaled() public { + // multiplier = 100_000 (10% of LP fee) + // pool500 (LP 500) -> 50 per direction + // pool3000 (LP 3000) -> 300 per direction (= PROTO_FEE_300) + // pool10000 (LP 10_000) -> 1000 per direction, clamped to MAX_PROTOCOL_FEE + vm.prank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(100_000)); + + PoolKey[] memory keys = new PoolKey[](3); + keys[0] = pool500; + keys[1] = pool3000; + keys[2] = pool10000; + adapter.batchTriggerFeeUpdate(keys); + vm.snapshotGasLastCall("fork: batchTriggerFeeUpdate 3 pools"); + + uint24 expected50 = (50 << 12) | 50; + (,, uint24 fee500,) = manager.getSlot0(pool500.toId()); + assertEq(fee500, expected50); + + (,, uint24 fee3000,) = manager.getSlot0(pool3000.toId()); + assertEq(fee3000, PROTO_FEE_300); + + uint24 expected1000 = (1000 << 12) | 1000; + (,, uint24 fee10000,) = manager.getSlot0(pool10000.toId()); + assertEq(fee10000, expected1000); + } + + // ============ Pool Override Bypasses Policy ============ + + function test_poolOverride_bypassesPolicy() public { + // 200_000 pips × pool500.fee (500) / 1_000_000 = 100 per direction (= PROTO_FEE_100) + vm.startPrank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(200_000)); + + // Override one pool to PROTO_FEE_500 + adapter.setPoolOverride(pool3000.toId(), PROTO_FEE_500); + vm.stopPrank(); + + // Trigger both + adapter.triggerFeeUpdate(pool3000); + adapter.triggerFeeUpdate(pool500); + + // pool3000 gets the override (multiplier-derived value would have been higher) + (,, uint24 fee3000,) = manager.getSlot0(pool3000.toId()); + assertEq(fee3000, PROTO_FEE_500); + + // pool500 gets the multiplier-derived fee + (,, uint24 fee500,) = manager.getSlot0(pool500.toId()); + assertEq(fee500, PROTO_FEE_100); + } + + // ============ Native Pair Class Fee Overrides Buckets ============ + + function test_nativePairClassFee_overridesBuckets() public { + vm.startPrank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(100_000)); + policy.setPairClassFee(currency0, currency1, policy.NATIVE_MATH_FAMILY_ID(), PROTO_FEE_300); + vm.stopPrank(); + + adapter.triggerFeeUpdate(pool3000); + + // Native pair class fee takes precedence over the bucket schedule + (,, uint24 fee,) = manager.getSlot0(pool3000.toId()); + assertEq(fee, PROTO_FEE_300); + } + + // ============ Fees Accrue From Multiple Swaps ============ + + function test_feesAccrueFromMultipleSwaps() public { + // 100_000 pips × pool3000.fee (3000) / 1_000_000 = 300 per direction (= PROTO_FEE_300) + vm.prank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(100_000)); + + adapter.triggerFeeUpdate(pool3000); + + // Execute 3 swaps in both directions + for (uint256 i; i < 3; ++i) { + swap(pool3000, true, -0.1e18, ZERO_BYTES); + swap(pool3000, false, -0.1e18, ZERO_BYTES); + } + + // Both currencies should have accrued fees + uint256 accrued0 = manager.protocolFeesAccrued(currency0); + uint256 accrued1 = manager.protocolFeesAccrued(currency1); + assertTrue(accrued0 > 0, "No fees accrued on currency0"); + assertTrue(accrued1 > 0, "No fees accrued on currency1"); + + // Collect both + IV4FeeAdapter.CollectParams[] memory params = new IV4FeeAdapter.CollectParams[](2); + params[0] = IV4FeeAdapter.CollectParams({currency: currency0, amount: 0}); + params[1] = IV4FeeAdapter.CollectParams({currency: currency1, amount: 0}); + adapter.collect(params); + vm.snapshotGasLastCall("fork: collect 2 currencies"); + + assertEq(MockERC20(Currency.unwrap(currency0)).balanceOf(tokenJar), accrued0); + assertEq(MockERC20(Currency.unwrap(currency1)).balanceOf(tokenJar), accrued1); + } + + // ============ Fee Update After Multiplier Change ============ + + function test_bucketsChange_requiresRetrigger() public { + // 33_334 pips × 3000 / 1_000_000 = 100 per direction (= PROTO_FEE_100, integer-truncated) + vm.prank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(33_334)); + adapter.triggerFeeUpdate(pool3000); + + (,, uint24 feeBefore,) = manager.getSlot0(pool3000.toId()); + assertEq(feeBefore, PROTO_FEE_100); + + // 166_667 pips × 3000 / 1_000_000 = 500 per direction (= PROTO_FEE_500, integer-truncated) + vm.prank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(166_667)); + + // Pool still has old fee until retriggered + (,, uint24 feeStale,) = manager.getSlot0(pool3000.toId()); + assertEq(feeStale, PROTO_FEE_100); + + // Retrigger picks up new multiplier + adapter.triggerFeeUpdate(pool3000); + (,, uint24 feeAfter,) = manager.getSlot0(pool3000.toId()); + assertEq(feeAfter, PROTO_FEE_500); + } + + // ============ Policy Swap ============ + + function test_policySwap_newPolicyTakesEffect() public { + // 100_000 pips × pool3000.fee (3000) / 1_000_000 = 300 per direction (= PROTO_FEE_300) + vm.prank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(100_000)); + adapter.triggerFeeUpdate(pool3000); + + (,, uint24 feeBefore,) = manager.getSlot0(pool3000.toId()); + assertEq(feeBefore, PROTO_FEE_300); + + // Deploy new policy with no multiplier configured (default 0, everything returns 0) + V4FeePolicy newPolicy = new V4FeePolicy(manager); + adapter.setPolicy(newPolicy); + + // Retrigger + adapter.triggerFeeUpdate(pool3000); + (,, uint24 feeAfter,) = manager.getSlot0(pool3000.toId()); + assertEq(feeAfter, 0); + } + + // ============ Explicit Zero Override Prevents Fee Accrual ============ + + function test_explicitZeroOverride_preventsFeeAccrual() public { + // 100_000 pips × pool3000.fee (3000) / 1_000_000 = 300 per direction (= PROTO_FEE_300) + vm.startPrank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(100_000)); + + // Override pool to explicit zero + adapter.setPoolOverride(pool3000.toId(), 0); + vm.stopPrank(); + + adapter.triggerFeeUpdate(pool3000); + + // Pool should have zero protocol fee + (,, uint24 fee,) = manager.getSlot0(pool3000.toId()); + assertEq(fee, 0); + + // Swap should not accrue any protocol fees + swap(pool3000, true, -1e18, ZERO_BYTES); + assertEq(manager.protocolFeesAccrued(currency0), 0); + assertEq(manager.protocolFeesAccrued(currency1), 0); + } + + // ============ Clear Override Restores Policy Behavior ============ + + function test_clearOverride_restoresPolicy() public { + // 100_000 pips × pool3000.fee (3000) / 1_000_000 = 300 per direction (= PROTO_FEE_300) + vm.startPrank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(100_000)); + adapter.setPoolOverride(pool3000.toId(), 0); // explicit zero + vm.stopPrank(); + + adapter.triggerFeeUpdate(pool3000); + (,, uint24 feeZero,) = manager.getSlot0(pool3000.toId()); + assertEq(feeZero, 0); + + // Clear override + vm.prank(feeSetter); + adapter.clearPoolOverride(pool3000.toId()); + + adapter.triggerFeeUpdate(pool3000); + (,, uint24 feeRestored,) = manager.getSlot0(pool3000.toId()); + assertEq(feeRestored, PROTO_FEE_300); + + // Swap now accrues fees + swap(pool3000, false, -1e18, ZERO_BYTES); + assertTrue(manager.protocolFeesAccrued(currency1) > 0, "Fees should accrue after clear"); + } + + // ============ Partial Collection ============ + + function test_partialCollection() public { + // 100_000 pips × pool3000.fee (3000) / 1_000_000 = 300 per direction (= PROTO_FEE_300) + vm.prank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(100_000)); + adapter.triggerFeeUpdate(pool3000); + + // Swap to accrue fees + swap(pool3000, false, -10e18, ZERO_BYTES); + uint256 totalAccrued = manager.protocolFeesAccrued(currency1); + assertTrue(totalAccrued > 0); + + // Collect only half + uint256 halfAmount = totalAccrued / 2; + IV4FeeAdapter.CollectParams[] memory params = new IV4FeeAdapter.CollectParams[](1); + params[0] = IV4FeeAdapter.CollectParams({currency: currency1, amount: halfAmount}); + adapter.collect(params); + + // Half still accrued, half in TokenJar + assertEq(manager.protocolFeesAccrued(currency1), totalAccrued - halfAmount); + assertEq(MockERC20(Currency.unwrap(currency1)).balanceOf(tokenJar), halfAmount); + } + + // ============ Asymmetric Fees ============ + + function test_asymmetricFees() public { + // 500 pips 0->1, 100 pips 1->0 + uint24 asymmetric = (100 << 12) | 500; + vm.prank(feeSetter); + adapter.setPoolOverride(pool3000.toId(), asymmetric); + adapter.triggerFeeUpdate(pool3000); + + (,, uint24 fee,) = manager.getSlot0(pool3000.toId()); + assertEq(fee, asymmetric); + + // Swap zeroForOne (input is currency0, protocol fee = 500 pips on 0->1) + swap(pool3000, true, -1e18, ZERO_BYTES); + uint256 accrued0 = manager.protocolFeesAccrued(currency0); + + // Swap oneForZero (input is currency1, protocol fee = 100 pips on 1->0) + swap(pool3000, false, -1e18, ZERO_BYTES); + uint256 accrued1 = manager.protocolFeesAccrued(currency1); + + // Both should have fees, and currency0 should have more (higher fee direction) + assertTrue(accrued0 > 0, "0->1 fees should accrue"); + assertTrue(accrued1 > 0, "1->0 fees should accrue"); + assertTrue(accrued0 > accrued1, "0->1 fee should be higher"); + } +} diff --git a/test/V4FeeAdapter.t.sol b/test/V4FeeAdapter.t.sol new file mode 100644 index 00000000..11f74d73 --- /dev/null +++ b/test/V4FeeAdapter.t.sol @@ -0,0 +1,2215 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol"; +import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; +import {PoolKey} from "v4-core/types/PoolKey.sol"; +import {PoolId, PoolIdLibrary} from "v4-core/types/PoolId.sol"; +import {Currency, CurrencyLibrary} from "v4-core/types/Currency.sol"; +import {IHooks} from "v4-core/interfaces/IHooks.sol"; +import {LPFeeLibrary} from "v4-core/libraries/LPFeeLibrary.sol"; +import {ProtocolFeeLibrary} from "v4-core/libraries/ProtocolFeeLibrary.sol"; + +import {V4FeeAdapter, IV4FeeAdapter} from "../src/feeAdapters/V4FeeAdapter.sol"; +import {V4FeePolicy, IV4FeePolicy} from "../src/feeAdapters/V4FeePolicy.sol"; +import { + FlagRule, + FeeBucket, + HookFamilyAssignment, + PairClassFeeAssignment, + PairClassFeeClear +} from "../src/interfaces/IV4FeePolicy.sol"; +import {HookFeeFlags} from "./utils/HookFeeFlags.sol"; +import {MockV4PoolManager} from "./mocks/MockV4PoolManager.sol"; +import { + MockFeeClassifiedHook, + GriefingHook, + RevertingHook, + ReturnBombHook +} from "./mocks/MockFeeClassifiedHook.sol"; + +contract V4FeeAdapterTest is Test { + using PoolIdLibrary for PoolKey; + using CurrencyLibrary for Currency; + + MockV4PoolManager public poolManager; + V4FeeAdapter public adapter; + V4FeePolicy public policy; + + address public owner; + address public feeSetter; + address public tokenJar; + address public alice; + + MockERC20 public token0; + MockERC20 public token1; + + // Standard pool keys for testing + PoolKey public standardKey; // static fee, no hook + PoolKey public hookKey; // static fee, non-custom-accounting hook + PoolKey public dynamicKey; // dynamic fee, no hook + + // Protocol fee constants (symmetric 0->1 and 1->0) + uint24 constant FEE_100 = (100 << 12) | 100; // 100 pips both directions + uint24 constant FEE_200 = (200 << 12) | 200; + uint24 constant FEE_300 = (300 << 12) | 300; + uint24 constant FEE_500 = (500 << 12) | 500; + uint24 constant FEE_1000 = (1000 << 12) | 1000; // max both directions + + function setUp() public { + owner = makeAddr("owner"); + feeSetter = makeAddr("feeSetter"); + tokenJar = makeAddr("tokenJar"); + alice = makeAddr("alice"); + + // Deploy tokens (sorted by address) + token0 = new MockERC20("Token0", "T0", 18); + token1 = new MockERC20("Token1", "T1", 18); + if (address(token0) > address(token1)) (token0, token1) = (token1, token0); + + // Deploy mock pool manager + vm.prank(owner); + poolManager = new MockV4PoolManager(owner); + + // Deploy policy and adapter + vm.startPrank(owner); + policy = new V4FeePolicy(IPoolManager(address(poolManager))); + adapter = new V4FeeAdapter(IPoolManager(address(poolManager)), tokenJar); + adapter.setPolicy(policy); + adapter.setFeeSetter(feeSetter); + policy.setFeeSetter(feeSetter); + + // Register adapter as protocolFeeController + poolManager.setProtocolFeeController(address(adapter)); + vm.stopPrank(); + + // Build standard pool keys + standardKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(address(0)) + }); + + // Hook at address with NO return-delta flags (bits 0-3 clear, bit 7 set = beforeSwap) + hookKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(address(uint160(1 << 7))) // beforeSwap only, no custom accounting + }); + + dynamicKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: LPFeeLibrary.DYNAMIC_FEE_FLAG, + tickSpacing: 60, + hooks: IHooks(address(0)) + }); + + // Initialize pools + poolManager.mockInitialize(standardKey); + poolManager.mockInitialize(hookKey); + poolManager.mockInitialize(dynamicKey); + } + + // ============ Helpers ============ + + /// @dev Slope value chosen so that `standardKey.fee = 3000` yields `FEE_300` + /// (300 pips per direction) under a single bucket starting at floor 0: + /// `0 + 100_000 × 3000 / 1_000_000 = 300`. + uint32 internal constant TEST_BETA_PIPS = 100_000; + + /// @dev Returns a single-bucket array `[(0, 0, betaPips)]`. Equivalent in math to a + /// pre-bucket-era global multiplier: `protocolFee = 0 + betaPips * lpFee / 1_000_000`. + /// Caller is responsible for the prank. + function _singleBucketSlope(uint32 betaPips) internal pure returns (FeeBucket[] memory bs) { + bs = new FeeBucket[](1); + bs[0] = FeeBucket({lpFeeFloor: 0, alphaPips: 0, betaPips: betaPips}); + } + + /// @dev Returns a single-bucket array `[(0, alphaPips, 0)]` — flat fee for any LP fee. + function _singleBucketFlat(uint24 alphaPips) internal pure returns (FeeBucket[] memory bs) { + bs = new FeeBucket[](1); + bs[0] = FeeBucket({lpFeeFloor: 0, alphaPips: alphaPips, betaPips: 0}); + } + + /// @dev As above but pranks feeSetter for one-off setups. + function _setSingleBucketSlope(uint32 betaPips) internal { + vm.prank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(betaPips)); + } + + /// @dev Adjusted Configuration B from the design discussion. All alpha values stay at + /// or below MAX_PROTOCOL_FEE = 1000 (the per-bucket cap). Continuity-preserving: + /// each bucket starts at the previous bucket's endpoint. + /// bucket 0: [0, 100) flat 0 + /// bucket 1: [100, 500) slope 10% (alpha 0, beta 100_000) + /// bucket 2: [500, 3000) starts at 40, slope 20% (alpha 40, beta 200_000) + /// bucket 3: [3000, 10_000) starts at 540, slope 15% (alpha 540, beta 150_000) + /// bucket 4: [10_000, ∞) flat ceiling at MAX_PROTOCOL_FEE + function _bucketsConfigB() internal pure returns (FeeBucket[] memory bs) { + bs = new FeeBucket[](5); + bs[0] = FeeBucket({lpFeeFloor: 0, alphaPips: 0, betaPips: 0}); + bs[1] = FeeBucket({lpFeeFloor: 100, alphaPips: 0, betaPips: 100_000}); + bs[2] = FeeBucket({lpFeeFloor: 500, alphaPips: 40, betaPips: 200_000}); + bs[3] = FeeBucket({lpFeeFloor: 3000, alphaPips: 540, betaPips: 150_000}); + bs[4] = FeeBucket({lpFeeFloor: 10_000, alphaPips: 1000, betaPips: 0}); + } + + function _pairHash() internal view returns (bytes32) { + return keccak256( + abi.encodePacked( + Currency.unwrap(standardKey.currency0), Currency.unwrap(standardKey.currency1) + ) + ); + } + + /// @dev Deploy a mock hook at a specific address using vm.etch. + /// The lowest 14 bits of the address encode hook permissions. + function _deployHookAt(uint160 addrFlags, uint256 feeFlags) internal returns (address) { + address hookAddr = address(addrFlags); + MockFeeClassifiedHook impl = new MockFeeClassifiedHook(feeFlags); + vm.etch(hookAddr, address(impl).code); + vm.store(hookAddr, bytes32(0), bytes32(feeFlags)); + return hookAddr; + } + + // ============ Adapter: Construction ============ + + function test_adapter_constructor() public view { + assertEq(address(adapter.POOL_MANAGER()), address(poolManager)); + assertEq(adapter.TOKEN_JAR(), tokenJar); + assertEq(address(adapter.policy()), address(policy)); + assertEq(adapter.feeSetter(), feeSetter); + } + + // ============ Adapter: Admin ============ + + function test_setPolicy_success() public { + V4FeePolicy newPolicy = new V4FeePolicy(IPoolManager(address(poolManager))); + vm.expectEmit(true, true, false, false, address(adapter)); + emit IV4FeeAdapter.PolicyUpdated(address(policy), address(newPolicy)); + vm.prank(owner); + adapter.setPolicy(newPolicy); + assertEq(address(adapter.policy()), address(newPolicy)); + } + + function test_setPolicy_revertsUnauthorized() public { + vm.prank(alice); + vm.expectRevert("UNAUTHORIZED"); + adapter.setPolicy(IV4FeePolicy(address(0))); + } + + function test_setPolicy_zeroDisablesPolicy() public { + vm.prank(owner); + adapter.setPolicy(IV4FeePolicy(address(0))); + assertEq(adapter.getFee(standardKey), 0); + } + + function test_setFeeSetter_adapter() public { + vm.expectEmit(true, true, false, false, address(adapter)); + emit IV4FeeAdapter.FeeSetterUpdated(feeSetter, alice); + vm.prank(owner); + adapter.setFeeSetter(alice); + assertEq(adapter.feeSetter(), alice); + } + + function test_setFeeSetter_adapter_revertsUnauthorized() public { + vm.prank(alice); + vm.expectRevert("UNAUTHORIZED"); + adapter.setFeeSetter(alice); + } + + // ============ Adapter: Pool Overrides ============ + + function test_setPoolOverride_success() public { + PoolId id = standardKey.toId(); + vm.expectEmit(true, false, false, true, address(adapter)); + emit IV4FeeAdapter.PoolOverrideUpdated(id, FEE_500); + vm.prank(feeSetter); + adapter.setPoolOverride(id, FEE_500); + vm.snapshotGasLastCall("adapter.setPoolOverride"); + assertEq(adapter.getFee(standardKey), FEE_500); + } + + function test_setPoolOverride_zeroSetsExplicitZero() public { + PoolId id = standardKey.toId(); + + // Configure policy to return FEE_300 + vm.prank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + assertEq(adapter.getFee(standardKey), FEE_300); + + // Set pool override to explicit zero -should NOT fall through to policy + vm.expectEmit(true, false, false, true, address(adapter)); + emit IV4FeeAdapter.PoolOverrideUpdated(id, type(uint24).max); + vm.prank(feeSetter); + adapter.setPoolOverride(id, 0); + + // Raw storage holds sentinel (explicit zero), getFee decodes to 0 + assertEq(adapter.poolOverrides(id), type(uint24).max); + assertEq(adapter.getFee(standardKey), 0); + } + + function test_clearPoolOverride_fallsThroughToPolicy() public { + PoolId id = standardKey.toId(); + + vm.prank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + + // Set override then clear it + vm.startPrank(feeSetter); + adapter.setPoolOverride(id, FEE_500); + assertEq(adapter.getFee(standardKey), FEE_500); + + vm.expectEmit(true, false, false, true, address(adapter)); + emit IV4FeeAdapter.PoolOverrideUpdated(id, 0); + adapter.clearPoolOverride(id); + vm.stopPrank(); + + // Raw storage is 0 (not set), falls through to policy + assertEq(adapter.poolOverrides(id), 0); + assertEq(adapter.getFee(standardKey), FEE_300); + } + + function test_clearPoolOverride_revertsUnauthorized() public { + vm.prank(alice); + vm.expectRevert(IV4FeeAdapter.Unauthorized.selector); + adapter.clearPoolOverride(standardKey.toId()); + } + + function test_setPoolOverride_revertsUnauthorized() public { + vm.prank(alice); + vm.expectRevert(IV4FeeAdapter.Unauthorized.selector); + adapter.setPoolOverride(standardKey.toId(), FEE_100); + } + + function test_setPoolOverride_revertsInvalidFee() public { + // Fee with 12-bit component > 1000 + uint24 badFee = (1001 << 12) | 500; + vm.prank(feeSetter); + vm.expectRevert(IV4FeeAdapter.InvalidFeeValue.selector); + adapter.setPoolOverride(standardKey.toId(), badFee); + } + + function test_poolOverride_takesPriorityOverPolicy() public { + // Configure policy to return FEE_300 via the multiplier path + vm.startPrank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + // Set pool override to FEE_500 + adapter.setPoolOverride(standardKey.toId(), FEE_500); + vm.stopPrank(); + + assertEq(adapter.getFee(standardKey), FEE_500); + vm.snapshotGasLastCall("adapter.getFee - pool override hit"); + } + + // ============ Adapter: Fee Triggering ============ + + function test_triggerFeeUpdate_success() public { + vm.prank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + + vm.expectEmit(true, true, false, true, address(adapter)); + emit IV4FeeAdapter.FeeUpdateTriggered(alice, standardKey.toId(), FEE_300); + vm.prank(alice); + adapter.triggerFeeUpdate(standardKey); + vm.snapshotGasLastCall("adapter.triggerFeeUpdate - single pool"); + + assertEq(poolManager.getProtocolFee(standardKey.toId()), FEE_300); + } + + function test_triggerFeeUpdate_skipsUninitializedPool() public { + PoolKey memory uninitKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 500, + tickSpacing: 10, + hooks: IHooks(address(0)) + }); + // Don't initialize -should not revert + adapter.triggerFeeUpdate(uninitKey); + assertEq(poolManager.getProtocolFee(uninitKey.toId()), 0); + } + + function testFuzz_triggerFeeUpdate_permissionless(address caller) public { + vm.assume(caller != address(0)); + vm.prank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + + vm.prank(caller); + adapter.triggerFeeUpdate(standardKey); + assertEq(poolManager.getProtocolFee(standardKey.toId()), FEE_300); + } + + function test_batchTriggerFeeUpdate_success() public { + vm.prank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + + PoolKey[] memory keys = new PoolKey[](2); + keys[0] = standardKey; + keys[1] = hookKey; + + adapter.batchTriggerFeeUpdate(keys); + vm.snapshotGasLastCall("adapter.batchTriggerFeeUpdate - two pools"); + + assertEq(poolManager.getProtocolFee(standardKey.toId()), FEE_300); + assertEq(poolManager.getProtocolFee(hookKey.toId()), FEE_300); + } + + // ============ Adapter: Collection ============ + + function test_collect_success() public { + Currency c = Currency.wrap(address(token0)); + uint256 amount = 1000e18; + token0.mint(address(poolManager), amount); + poolManager.setProtocolFeesAccrued(c, amount); + + IV4FeeAdapter.CollectParams[] memory params = new IV4FeeAdapter.CollectParams[](1); + params[0] = IV4FeeAdapter.CollectParams({currency: c, amount: amount}); + + vm.expectEmit(true, false, false, true, address(adapter)); + emit IV4FeeAdapter.FeesCollected(c, amount); + adapter.collect(params); + vm.snapshotGasLastCall("adapter.collect - single currency"); + + assertEq(token0.balanceOf(tokenJar), amount); + } + + function test_collect_zeroCollectsAll() public { + Currency c = Currency.wrap(address(token0)); + uint256 amount = 500e18; + token0.mint(address(poolManager), amount); + poolManager.setProtocolFeesAccrued(c, amount); + + IV4FeeAdapter.CollectParams[] memory params = new IV4FeeAdapter.CollectParams[](1); + params[0] = IV4FeeAdapter.CollectParams({currency: c, amount: 0}); + + adapter.collect(params); + assertEq(token0.balanceOf(tokenJar), amount); + } + + // ============ Policy: Construction ============ + + function test_policy_constructor() public view { + assertEq(address(policy.POOL_MANAGER()), address(poolManager)); + assertEq(policy.feeSetter(), feeSetter); + assertEq(policy.CUSTOM_ACCOUNTING_MASK(), 0xF); + } + + // ============ Policy: Admin ============ + + function test_setFeeSetter_policy() public { + vm.expectEmit(true, true, false, false, address(policy)); + emit IV4FeePolicy.FeeSetterUpdated(feeSetter, alice); + vm.prank(owner); + policy.setFeeSetter(alice); + assertEq(policy.feeSetter(), alice); + } + + function test_setFeeSetter_policy_revertsUnauthorized() public { + vm.prank(alice); + vm.expectRevert("UNAUTHORIZED"); + policy.setFeeSetter(alice); + } + + // ============ Policy: isCustomAccounting ============ + + function test_isCustomAccounting_noHook() public view { + assertFalse(policy.isCustomAccounting(address(0))); + } + + function test_isCustomAccounting_noDeltaFlags() public view { + // Address with only bit 7 set (beforeSwap) -no custom accounting + assertFalse(policy.isCustomAccounting(address(uint160(1 << 7)))); + } + + function test_isCustomAccounting_beforeSwapReturnsDelta() public view { + // Bit 3 = BEFORE_SWAP_RETURNS_DELTA + assertTrue(policy.isCustomAccounting(address(uint160(1 << 3)))); + } + + function test_isCustomAccounting_afterSwapReturnsDelta() public view { + // Bit 2 = AFTER_SWAP_RETURNS_DELTA + assertTrue(policy.isCustomAccounting(address(uint160(1 << 2)))); + } + + function test_isCustomAccounting_allDeltaFlags() public view { + // Bits 0-3 all set + assertTrue(policy.isCustomAccounting(address(uint160(0xF)))); + } + + function testFuzz_isCustomAccounting(uint160 addr) public view { + bool expected = addr & 0xF != 0; + assertEq(policy.isCustomAccounting(address(addr)), expected); + } + + // ============ Policy: Fee Buckets ============ + + function test_setFeeBuckets_success() public { + FeeBucket[] memory bs = _singleBucketSlope(TEST_BETA_PIPS); + + vm.expectEmit(false, false, false, true, address(policy)); + emit IV4FeePolicy.FeeBucketsUpdated(1); + vm.prank(feeSetter); + policy.setFeeBuckets(bs); + + vm.snapshotGasLastCall("policy.setFeeBuckets - 1 bucket"); + assertEq(policy.feeBucketsLength(), 1); + (uint24 floor, uint24 alpha, uint32 beta) = policy.feeBucket(0); + assertEq(floor, 0); + assertEq(alpha, 0); + assertEq(beta, TEST_BETA_PIPS); + } + + function test_setFeeBuckets_5buckets_configurationB() public { + FeeBucket[] memory bs = _bucketsConfigB(); + + vm.expectEmit(false, false, false, true, address(policy)); + emit IV4FeePolicy.FeeBucketsUpdated(5); + vm.prank(feeSetter); + policy.setFeeBuckets(bs); + + vm.snapshotGasLastCall("policy.setFeeBuckets - 5 buckets (configuration B)"); + assertEq(policy.feeBucketsLength(), 5); + } + + function test_setFeeBuckets_revertsEmpty() public { + FeeBucket[] memory bs = new FeeBucket[](0); + vm.prank(feeSetter); + vm.expectRevert(IV4FeePolicy.EmptyBuckets.selector); + policy.setFeeBuckets(bs); + } + + function test_setFeeBuckets_revertsNotAscending() public { + FeeBucket[] memory bs = new FeeBucket[](2); + bs[0] = FeeBucket({lpFeeFloor: 100, alphaPips: 0, betaPips: 0}); + bs[1] = FeeBucket({lpFeeFloor: 50, alphaPips: 0, betaPips: 0}); + vm.prank(feeSetter); + vm.expectRevert(IV4FeePolicy.BucketsNotAscending.selector); + policy.setFeeBuckets(bs); + } + + function test_setFeeBuckets_revertsEqualFloors() public { + // Strict ascending: equal floors must also revert. + FeeBucket[] memory bs = new FeeBucket[](2); + bs[0] = FeeBucket({lpFeeFloor: 100, alphaPips: 0, betaPips: 0}); + bs[1] = FeeBucket({lpFeeFloor: 100, alphaPips: 0, betaPips: 0}); + vm.prank(feeSetter); + vm.expectRevert(IV4FeePolicy.BucketsNotAscending.selector); + policy.setFeeBuckets(bs); + } + + function test_setFeeBuckets_revertsAlphaTooLarge() public { + FeeBucket[] memory bs = new FeeBucket[](1); + bs[0] = FeeBucket({lpFeeFloor: 0, alphaPips: 1001, betaPips: 0}); + vm.prank(feeSetter); + vm.expectRevert(IV4FeePolicy.InvalidFeeValue.selector); + policy.setFeeBuckets(bs); + } + + function test_setFeeBuckets_revertsBetaTooLarge() public { + FeeBucket[] memory bs = new FeeBucket[](1); + bs[0] = FeeBucket({lpFeeFloor: 0, alphaPips: 0, betaPips: 1_000_000_001}); + vm.prank(feeSetter); + vm.expectRevert(IV4FeePolicy.MultiplierTooLarge.selector); + policy.setFeeBuckets(bs); + } + + function test_setFeeBuckets_revertsTooManyBuckets() public { + FeeBucket[] memory bs = new FeeBucket[](17); + for (uint256 i; i < 17; ++i) { + bs[i] = FeeBucket({lpFeeFloor: uint24(i * 100), alphaPips: 0, betaPips: 0}); + } + vm.prank(feeSetter); + vm.expectRevert(IV4FeePolicy.TooManyBuckets.selector); + policy.setFeeBuckets(bs); + } + + function test_setFeeBuckets_acceptsAlphaBoundary() public { + FeeBucket[] memory bs = new FeeBucket[](1); + bs[0] = FeeBucket({lpFeeFloor: 0, alphaPips: 1000, betaPips: 0}); + vm.prank(feeSetter); + policy.setFeeBuckets(bs); + (, uint24 alpha,) = policy.feeBucket(0); + assertEq(alpha, 1000); + } + + function test_setFeeBuckets_acceptsBetaBoundary() public { + FeeBucket[] memory bs = new FeeBucket[](1); + bs[0] = FeeBucket({lpFeeFloor: 0, alphaPips: 0, betaPips: 1_000_000_000}); + vm.prank(feeSetter); + policy.setFeeBuckets(bs); + (,, uint32 beta) = policy.feeBucket(0); + assertEq(beta, 1_000_000_000); + } + + function test_setFeeBuckets_acceptsMaxBuckets() public { + FeeBucket[] memory bs = new FeeBucket[](16); + for (uint256 i; i < 16; ++i) { + bs[i] = FeeBucket({lpFeeFloor: uint24(i * 100), alphaPips: 0, betaPips: 0}); + } + vm.prank(feeSetter); + policy.setFeeBuckets(bs); + assertEq(policy.feeBucketsLength(), 16); + } + + function test_setFeeBuckets_revertsUnauthorized() public { + vm.prank(alice); + vm.expectRevert(IV4FeePolicy.Unauthorized.selector); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + } + + function test_setFeeBuckets_replacesExisting() public { + vm.startPrank(feeSetter); + policy.setFeeBuckets(_bucketsConfigB()); + assertEq(policy.feeBucketsLength(), 5); + + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + assertEq(policy.feeBucketsLength(), 1); + vm.stopPrank(); + } + + function test_clearFeeBuckets() public { + vm.startPrank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + assertEq(policy.feeBucketsLength(), 1); + + vm.expectEmit(false, false, false, true, address(policy)); + emit IV4FeePolicy.FeeBucketsUpdated(0); + policy.clearFeeBuckets(); + vm.stopPrank(); + + assertEq(policy.feeBucketsLength(), 0); + // With no buckets and no pair fee, computeFee on standardKey returns 0. + assertEq(policy.computeFee(standardKey), 0); + } + + function test_clearFeeBuckets_revertsUnauthorized() public { + vm.prank(alice); + vm.expectRevert(IV4FeePolicy.Unauthorized.selector); + policy.clearFeeBuckets(); + } + + // ============ Policy: computeFee - static native math path ============ + + function test_computeFee_staticNativeMath_singleBucketSlope() public { + vm.prank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + + // key.fee = 3000, single bucket (0, 0, 100_000) -> 0 + 100_000 * 3000 / 1_000_000 = 300 + assertEq(policy.computeFee(standardKey), FEE_300); + vm.snapshotGasLastCall("policy.computeFee - static native math buckets"); + } + + function test_computeFee_staticNativeMath_singleBucketSlope_lowFee() public { + vm.prank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + + PoolKey memory lowFeeKey = standardKey; + lowFeeKey.fee = 100; + poolManager.mockInitialize(lowFeeKey); + + // key.fee = 100 -> 100 * 100_000 / 1_000_000 = 10 per direction + uint24 expected = (10 << 12) | 10; + assertEq(policy.computeFee(lowFeeKey), expected); + } + + function test_computeFee_staticNativeMath_pairClassFeeOverridesBuckets() public { + vm.startPrank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + policy.setPairClassFee( + standardKey.currency0, standardKey.currency1, policy.NATIVE_MATH_FAMILY_ID(), FEE_500 + ); + vm.stopPrank(); + + // Native pair class fee should override the bucket-derived fee + assertEq(policy.computeFee(standardKey), FEE_500); + vm.snapshotGasLastCall("policy.computeFee - static native math pair class fee"); + } + + function test_computeFee_staticNativeMath_zeroBucketsReturnsZero() public view { + assertEq(policy.computeFee(standardKey), 0); + } + + function test_computeFee_staticNativeMath_hookWithoutDeltaFlags() public { + vm.prank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + + // hookKey has address with bit 7 set but bits 0-3 clear -> StaticNativeMath path + assertEq(policy.computeFee(hookKey), FEE_300); + } + + function test_computeFee_staticNativeMath_governanceFamilyUsesClassifiedWaterfall() public { + vm.startPrank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + policy.setHookFamily(address(hookKey.hooks), 1); + policy.setFamilyDefault(1, FEE_200); + vm.stopPrank(); + + assertEq(policy.computeFee(hookKey), FEE_200); + } + + function test_familyIdConstants_nativeMath255UnclassifiedZero() public view { + assertEq(policy.NATIVE_MATH_FAMILY_ID(), 0xFF); + assertEq(policy.UNCLASSIFIED_FAMILY_ID(), 0); + } + + function test_computeFee_unclassifiedCustomAccounting_ignoresNativeMathBuckets() public { + address customHook = address(uint160(1 << 2)); + PoolKey memory customKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(customHook) + }); + poolManager.mockInitialize(customKey); + + vm.startPrank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + policy.setDefaultFee(FEE_100); + vm.stopPrank(); + + // family 0 (unclassified) must not read fee buckets + assertEq(policy.computeFee(customKey), FEE_100); + } + + function test_computeFee_staticHook_governanceFamily255_skipsFamilyDefaultUsesBuckets() public { + uint8 nativeFamily = policy.NATIVE_MATH_FAMILY_ID(); + + vm.startPrank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + policy.setHookFamily(address(hookKey.hooks), nativeFamily); + vm.stopPrank(); + + // family 255 uses native-math branch (buckets), not familyDefaults[255] + assertEq(policy.computeFee(hookKey), FEE_300); + } + + function test_computeFee_staticHook_governanceFamily255_pairClassFeeOverridesBuckets() public { + uint8 nativeFamily = policy.NATIVE_MATH_FAMILY_ID(); + + vm.startPrank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + policy.setHookFamily(address(hookKey.hooks), nativeFamily); + policy.setPairClassFee(hookKey.currency0, hookKey.currency1, nativeFamily, FEE_200); + vm.stopPrank(); + + assertEq(policy.computeFee(hookKey), FEE_200); + } + + function test_computeFee_staticHook_governanceFamily255_explicitZeroPairClassFee() public { + uint8 nativeFamily = policy.NATIVE_MATH_FAMILY_ID(); + + vm.startPrank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + policy.setHookFamily(address(hookKey.hooks), nativeFamily); + policy.setPairClassFee(hookKey.currency0, hookKey.currency1, nativeFamily, 0); + vm.stopPrank(); + + assertEq(policy.computeFee(hookKey), 0); + } + + function test_computeFee_staticHook_governanceFamilyZeroExemptViaFamilyDefault() public { + vm.startPrank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + policy.setHookFamily(address(hookKey.hooks), 1); + policy.setFamilyDefault(1, 0); + vm.stopPrank(); + + assertEq(policy.computeFee(hookKey), 0); + } + + function test_setHookFamily_acceptsFamily255() public { + uint8 nativeFamily = policy.NATIVE_MATH_FAMILY_ID(); + + vm.prank(feeSetter); + policy.setHookFamily(address(hookKey.hooks), nativeFamily); + + assertEq(policy.hookFamilyId(address(hookKey.hooks)), nativeFamily); + } + + // ============ Unified resolution regression ============ + + function test_computeFee_dynamicFee_unclassified_ignoresNativeMathBuckets() public { + vm.startPrank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + policy.setDefaultFee(FEE_100); + vm.stopPrank(); + + assertEq(policy.computeFee(dynamicKey), FEE_100); + } + + function test_computeFee_dynamicFee_governancePairClassFeeBeatsFamilyDefault() public { + vm.startPrank(feeSetter); + policy.setHookFamily(address(0), 2); + policy.setFamilyDefault(2, FEE_300); + policy.setPairClassFee(standardKey.currency0, standardKey.currency1, 2, FEE_100); + vm.stopPrank(); + + assertEq(policy.computeFee(dynamicKey), FEE_100); + } + + function test_computeFee_dynamicFee_governanceFamilyDefaultWhenNoPairFee() public { + vm.startPrank(feeSetter); + policy.setHookFamily(address(0), 2); + policy.setFamilyDefault(2, FEE_300); + vm.stopPrank(); + + assertEq(policy.computeFee(dynamicKey), FEE_300); + } + + function test_computeFee_clearHookFamily_restoresNativeMathBuckets() public { + vm.startPrank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + policy.setHookFamily(address(hookKey.hooks), 1); + policy.setFamilyDefault(1, FEE_200); + policy.setHookFamily(address(hookKey.hooks), 0); + vm.stopPrank(); + + assertEq(policy.computeFee(hookKey), FEE_300); + } + + function test_computeFee_classified_noFamilyConfigFallsToDefaultFee() public { + address customHook = address(uint160(1 << 2)); + PoolKey memory customKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(customHook) + }); + poolManager.mockInitialize(customKey); + + vm.startPrank(feeSetter); + policy.setDefaultFee(FEE_100); + policy.setHookFamily(customHook, 4); + vm.stopPrank(); + + assertEq(policy.computeFee(customKey), FEE_100); + } + + function test_computeFee_flagRule_family255_usesNativeMathBranchNotFamilyDefault() public { + uint160 addrFlags = (1 << 7) | (1 << 2); + address hookAddr = address(addrFlags); + MockFeeClassifiedHook impl = new MockFeeClassifiedHook(HookFeeFlags.STABLE_PAIR); + vm.etch(hookAddr, address(impl).code); + + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(hookAddr) + }); + poolManager.mockInitialize(key); + + uint8 nativeFamily = policy.NATIVE_MATH_FAMILY_ID(); + FlagRule[] memory rules = new FlagRule[](1); + rules[0] = FlagRule({requiredFlags: HookFeeFlags.STABLE_PAIR, familyId: nativeFamily}); + + vm.startPrank(feeSetter); + policy.setFlagRules(rules); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + vm.stopPrank(); + + assertEq(policy.computeFee(key), FEE_300); + } + + function test_computeFee_flagRule_family255_pairClassFeeOverridesBuckets() public { + uint160 addrFlags = (1 << 7) | (1 << 2); + address hookAddr = address(addrFlags); + MockFeeClassifiedHook impl = new MockFeeClassifiedHook(HookFeeFlags.STABLE_PAIR); + vm.etch(hookAddr, address(impl).code); + + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(hookAddr) + }); + poolManager.mockInitialize(key); + + uint8 nativeFamily = policy.NATIVE_MATH_FAMILY_ID(); + FlagRule[] memory rules = new FlagRule[](1); + rules[0] = FlagRule({requiredFlags: HookFeeFlags.STABLE_PAIR, familyId: nativeFamily}); + + vm.startPrank(feeSetter); + policy.setFlagRules(rules); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + policy.setPairClassFee(key.currency0, key.currency1, nativeFamily, FEE_200); + vm.stopPrank(); + + assertEq(policy.computeFee(key), FEE_200); + } + + function test_computeFee_governanceFamily255_onCustomAccounting_usesNativeMathBranch() public { + address customHook = address(uint160((1 << 7) | (1 << 2))); + PoolKey memory customKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(customHook) + }); + poolManager.mockInitialize(customKey); + + uint8 nativeFamily = policy.NATIVE_MATH_FAMILY_ID(); + + vm.startPrank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + policy.setHookFamily(customHook, nativeFamily); + vm.stopPrank(); + + assertEq(policy.computeFee(customKey), FEE_300); + } + + function test_computeFee_standardKey_autoNativeFamily_usesBucketsWithoutGovernance() public view { + // address(0) hook + static fee resolves to NATIVE_MATH_FAMILY_ID internally + assertEq(policy.computeFee(standardKey), 0); + } + + function test_computeFee_unclassifiedDynamicFee_notConfusedWithNativeFamily255() public { + vm.startPrank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + policy.setDefaultFee(FEE_100); + vm.stopPrank(); + + assertEq(policy.computeFee(dynamicKey), FEE_100); + } + + // ============ Policy: computeFee bucket math ============ + + function test_computeFee_staticNativeMath_singleBucketFlat() public { + vm.prank(feeSetter); + policy.setFeeBuckets(_singleBucketFlat(25)); + + // alpha=25, beta=0 → flat 25 pips for any LP fee + uint24 expected = (25 << 12) | 25; + assertEq(policy.computeFee(standardKey), expected); // key.fee = 3000 + + PoolKey memory zeroFeeKey = standardKey; + zeroFeeKey.fee = 0; + poolManager.mockInitialize(zeroFeeKey); + // snap-to-lowest with delta = 0 → still alpha = 25 + assertEq(policy.computeFee(zeroFeeKey), expected); + } + + function test_computeFee_staticNativeMath_singleBucket1to1() public { + // 1:1 boundary: beta = 1_000_000 means protocol fee == LP fee + FeeBucket[] memory bs = new FeeBucket[](1); + bs[0] = FeeBucket({lpFeeFloor: 0, alphaPips: 0, betaPips: 1_000_000}); + vm.prank(feeSetter); + policy.setFeeBuckets(bs); + + PoolKey memory k = standardKey; + k.fee = 1000; + poolManager.mockInitialize(k); + // 1000 * 1_000_000 / 1_000_000 = 1000 per direction (clamp boundary) + assertEq(policy.computeFee(k), FEE_1000); + } + + function test_computeFee_staticNativeMath_clamps() public { + FeeBucket[] memory bs = new FeeBucket[](1); + bs[0] = FeeBucket({lpFeeFloor: 0, alphaPips: 0, betaPips: 1_000_000}); + vm.prank(feeSetter); + policy.setFeeBuckets(bs); + + PoolKey memory k = standardKey; + k.fee = 5000; + poolManager.mockInitialize(k); + // 5000 * 1 = 5000, clamped to MAX_PROTOCOL_FEE = 1000 per direction + assertEq(policy.computeFee(k), FEE_1000); + } + + function test_computeFee_staticNativeMath_zeroLpFee_alphaZero() public { + vm.prank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + + PoolKey memory zeroFeeKey = standardKey; + zeroFeeKey.fee = 0; + poolManager.mockInitialize(zeroFeeKey); + // alpha = 0 + beta * 0 / 1_000_000 = 0 + assertEq(policy.computeFee(zeroFeeKey), 0); + } + + function test_computeFee_staticNativeMath_zeroLpFee_alphaNonzero() public { + vm.prank(feeSetter); + policy.setFeeBuckets(_singleBucketFlat(25)); + + PoolKey memory zeroFeeKey = standardKey; + zeroFeeKey.fee = 0; + poolManager.mockInitialize(zeroFeeKey); + uint24 expected = (25 << 12) | 25; + assertEq(policy.computeFee(zeroFeeKey), expected); + } + + function test_computeFee_staticNativeMath_snapToLowest() public { + // Two buckets where the lowest floor is > 0. lpFee < floor_0 must snap to bucket 0. + FeeBucket[] memory bs = new FeeBucket[](2); + bs[0] = FeeBucket({lpFeeFloor: 100, alphaPips: 25, betaPips: 0}); + bs[1] = FeeBucket({lpFeeFloor: 500, alphaPips: 50, betaPips: 0}); + vm.prank(feeSetter); + policy.setFeeBuckets(bs); + + PoolKey memory k = standardKey; + k.fee = 50; // below floor_0 = 100 + poolManager.mockInitialize(k); + + // Snap to bucket 0 with delta = 0 -> alpha_0 = 25 + uint24 expected = (25 << 12) | 25; + assertEq(policy.computeFee(k), expected); + } + + function test_computeFee_staticNativeMath_continuousPiecewise() public { + vm.prank(feeSetter); + policy.setFeeBuckets(_bucketsConfigB()); + + // Spot-check several lpFee values against the curve table: + // lpFee=50 bucket 0 (flat 0) -> 0 + // lpFee=100 bucket 1 starts (delta=0) -> 0 + // lpFee=500 bucket 2 starts (delta=0) -> 40 + // lpFee=3000 bucket 3 starts (delta=0) -> 540 + // lpFee=10000 bucket 4 starts (flat 1000) -> 1000 (clamp territory) + // lpFee=200 bucket 1, delta=100 -> 0 + 100_000*100/1e6 = 10 + // lpFee=1000 bucket 2, delta=500 -> 40 + 200_000*500/1e6 = 140 + // lpFee=5000 bucket 3, delta=2000 -> 540 + 150_000*2000/1e6 = 840 + PoolKey memory k = standardKey; + + k.fee = 50; + poolManager.mockInitialize(k); + assertEq(policy.computeFee(k), 0); + + k.fee = 100; + poolManager.mockInitialize(k); + assertEq(policy.computeFee(k), 0); + + k.fee = 500; + poolManager.mockInitialize(k); + assertEq(policy.computeFee(k), (40 << 12) | 40); + + k.fee = 3000; + assertEq(policy.computeFee(k), (540 << 12) | 540); + + k.fee = 10_000; + poolManager.mockInitialize(k); + assertEq(policy.computeFee(k), FEE_1000); + + k.fee = 200; + poolManager.mockInitialize(k); + assertEq(policy.computeFee(k), (10 << 12) | 10); + + k.fee = 1000; + poolManager.mockInitialize(k); + assertEq(policy.computeFee(k), (140 << 12) | 140); + + k.fee = 5000; + poolManager.mockInitialize(k); + assertEq(policy.computeFee(k), (840 << 12) | 840); + } + + function test_computeFee_staticNativeMath_discontinuousPiecewise() public { + // Intentional cliff: alpha jumps at the boundary + FeeBucket[] memory bs = new FeeBucket[](2); + bs[0] = FeeBucket({lpFeeFloor: 0, alphaPips: 25, betaPips: 0}); + bs[1] = FeeBucket({lpFeeFloor: 100, alphaPips: 100, betaPips: 0}); + vm.prank(feeSetter); + policy.setFeeBuckets(bs); + + PoolKey memory k = standardKey; + k.fee = 99; + poolManager.mockInitialize(k); + assertEq(policy.computeFee(k), (25 << 12) | 25); + + k.fee = 100; + poolManager.mockInitialize(k); + assertEq(policy.computeFee(k), (100 << 12) | 100); + } + + function test_computeFee_staticNativeMath_pairClassFeeBeatsBuckets() public { + vm.startPrank(feeSetter); + policy.setFeeBuckets(_bucketsConfigB()); + policy.setPairClassFee( + standardKey.currency0, standardKey.currency1, policy.NATIVE_MATH_FAMILY_ID(), FEE_200 + ); + vm.stopPrank(); + + // Native pair class fee wins over bucket-derived fee + assertEq(policy.computeFee(standardKey), FEE_200); + } + + function test_computeFee_dynamicFee_skipsBuckets() public { + // Regression: a future routing bug must not evaluate buckets for DYNAMIC_FEE_FLAG. + // Dynamic-fee pools must take the classified path. + vm.startPrank(feeSetter); + policy.setFeeBuckets(_bucketsConfigB()); + policy.setDefaultFee(FEE_100); + vm.stopPrank(); + + // dynamicKey.fee == LPFeeLibrary.DYNAMIC_FEE_FLAG -> classified -> defaultFee + assertEq(policy.computeFee(dynamicKey), FEE_100); + } + + function test_computeFee_dynamicFee_forcedToNativeFamily_fallsThroughToDefault() public { + // Regression (audit item 1): governance can force a pool to family 255 via + // setHookFamily. For a dynamic-fee pool that means key.fee == DYNAMIC_FEE_FLAG + // (0x800000) would be fed into the bucket curve, which clamps to MAX_PROTOCOL_FEE. + // The native-math branch must instead fall through to defaultFee for dynamic keys. + vm.startPrank(feeSetter); + policy.setFeeBuckets(_bucketsConfigB()); + policy.setDefaultFee(FEE_100); + // dynamicKey.hooks == address(0); force its (hook-keyed) family to native math. + policy.setHookFamily(address(dynamicKey.hooks), policy.NATIVE_MATH_FAMILY_ID()); + vm.stopPrank(); + + assertEq(policy.computeFee(dynamicKey), FEE_100); // defaultFee, not bucket-clamped + assertTrue(policy.computeFee(dynamicKey) != FEE_1000); // not MAX_PROTOCOL_FEE + } + + function test_computeFee_dynamicFee_forcedToNativeFamily_pairOverrideStillWins() public { + // The dynamic-key guard must not shadow an explicit pairClassFees[pair][255] + // override: that check runs before the native-math branch. + uint8 nativeFamily = policy.NATIVE_MATH_FAMILY_ID(); + vm.startPrank(feeSetter); + policy.setFeeBuckets(_bucketsConfigB()); + policy.setDefaultFee(FEE_100); + policy.setHookFamily(address(dynamicKey.hooks), nativeFamily); + policy.setPairClassFee(dynamicKey.currency0, dynamicKey.currency1, nativeFamily, FEE_200); + vm.stopPrank(); + + assertEq(policy.computeFee(dynamicKey), FEE_200); // explicit pair override honored + } + + function testFuzz_computeFee_staticNativeMath_buckets(uint24 lpFee) public { + lpFee = uint24(bound(lpFee, 0, LPFeeLibrary.MAX_LP_FEE)); + vm.prank(feeSetter); + policy.setFeeBuckets(_bucketsConfigB()); + + PoolKey memory k = standardKey; + k.fee = lpFee; + poolManager.mockInitialize(k); + + uint24 fee = policy.computeFee(k); + uint24 zeroForOne = fee & 0xFFF; + uint24 oneForZero = fee >> 12; + assertEq(zeroForOne, oneForZero); // symmetric: both 12-bit components equal + assertLe(zeroForOne, ProtocolFeeLibrary.MAX_PROTOCOL_FEE); + } + + // ============ Policy: computeFee - classified path ============ + + function test_computeFee_classified_familyDefault() public { + // Create a pool with a custom-accounting hook (bit 2 = afterSwapReturnsDelta) + address customHook = address(uint160((1 << 7) | (1 << 2))); // beforeSwap + + // afterSwapReturnsDelta + PoolKey memory customKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(customHook) + }); + poolManager.mockInitialize(customKey); + + vm.startPrank(feeSetter); + policy.setHookFamily(customHook, 1); + policy.setFamilyDefault(1, FEE_200); + vm.stopPrank(); + + assertEq(policy.computeFee(customKey), FEE_200); + vm.snapshotGasLastCall("policy.computeFee - classified family default"); + } + + function test_computeFee_classified_pairClassFeeLiteral() public { + address customHook = address(uint160((1 << 7) | (1 << 2))); + PoolKey memory customKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(customHook) + }); + poolManager.mockInitialize(customKey); + + vm.startPrank(feeSetter); + policy.setHookFamily(customHook, 1); + policy.setPairClassFee(customKey.currency0, customKey.currency1, 1, FEE_100); + vm.stopPrank(); + + assertEq(policy.computeFee(customKey), FEE_100); + vm.snapshotGasLastCall("policy.computeFee - classified pair class fee"); + } + + function test_computeFee_classified_clearPairClassFeeFallsBackToFamilyDefault() public { + address customHook = address(uint160((1 << 7) | (1 << 2))); + PoolKey memory customKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(customHook) + }); + poolManager.mockInitialize(customKey); + + uint24 fee50 = (50 << 12) | 50; + + vm.startPrank(feeSetter); + policy.setHookFamily(customHook, 1); + policy.setPairClassFee(customKey.currency0, customKey.currency1, 1, FEE_100); + policy.setFamilyDefault(1, fee50); + vm.stopPrank(); + + assertEq(policy.computeFee(customKey), FEE_100); + + vm.prank(feeSetter); + policy.clearPairClassFee(customKey.currency0, customKey.currency1, 1); + + assertEq(policy.computeFee(customKey), fee50); + } + + function test_computeFee_classified_nativePairClassFeeDoesNotApply() public { + address customHook = address(uint160((1 << 7) | (1 << 2))); + PoolKey memory customKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(customHook) + }); + poolManager.mockInitialize(customKey); + + vm.startPrank(feeSetter); + policy.setPairClassFee( + customKey.currency0, customKey.currency1, policy.NATIVE_MATH_FAMILY_ID(), FEE_500 + ); + policy.setHookFamily(customHook, 1); + policy.setFamilyDefault(1, FEE_200); + vm.stopPrank(); + + // NATIVE_MATH_FAMILY_ID slot is not read on the classified path + assertEq(policy.computeFee(customKey), FEE_200); + } + + function test_computeFee_classified_explicitZeroPairClassFeeDoesNotFallBack() public { + address customHook = address(uint160((1 << 7) | (1 << 2))); + PoolKey memory customKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(customHook) + }); + poolManager.mockInitialize(customKey); + + vm.startPrank(feeSetter); + policy.setHookFamily(customHook, 1); + policy.setPairClassFee(customKey.currency0, customKey.currency1, 1, 0); + policy.setFamilyDefault(1, FEE_200); + vm.stopPrank(); + + assertEq(policy.computeFee(customKey), 0); + } + + function test_computeFee_classified_pairClassFeeAtMax() public { + address customHook = address(uint160((1 << 7) | (1 << 2))); + PoolKey memory customKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(customHook) + }); + poolManager.mockInitialize(customKey); + + vm.startPrank(feeSetter); + policy.setHookFamily(customHook, 1); + policy.setPairClassFee(customKey.currency0, customKey.currency1, 1, FEE_1000); + vm.stopPrank(); + + assertEq(policy.computeFee(customKey), FEE_1000); + } + + function test_computeFee_classified_unclassifiedFallsToDefault() public { + address customHook = address(uint160(1 << 2)); // custom accounting, no family set + PoolKey memory customKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(customHook) + }); + poolManager.mockInitialize(customKey); + + vm.prank(feeSetter); + policy.setDefaultFee(FEE_100); + + assertEq(policy.computeFee(customKey), FEE_100); + vm.snapshotGasLastCall("policy.computeFee - classified unclassified -> defaultFee"); + } + + function test_computeFee_classified_unclassifiedNoDefaultReturnsZero() public { + address customHook = address(uint160(1 << 2)); + PoolKey memory customKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(customHook) + }); + poolManager.mockInitialize(customKey); + + assertEq(policy.computeFee(customKey), 0); + } + + function test_computeFee_dynamicFee_requiresClassification() public { + // Dynamic fee pool with no hook -> classified path, hookFamilyId[address(0)] = 0 + vm.prank(feeSetter); + policy.setDefaultFee(FEE_100); + + assertEq(policy.computeFee(dynamicKey), FEE_100); + } + + function test_computeFee_dynamicFee_withFamily() public { + // Dynamic fee pool at address(0) -familyId lookup for address(0) + vm.startPrank(feeSetter); + policy.setHookFamily(address(0), 2); + policy.setFamilyDefault(2, FEE_300); + vm.stopPrank(); + + assertEq(policy.computeFee(dynamicKey), FEE_300); + } + + // ============ Policy: Hook Self-Report ============ + + function test_computeFee_selfReport_usedWhenNoGovernanceOverride() public { + // Deploy a self-reporting hook at an address with custom accounting flags + uint160 addrFlags = (1 << 7) | (1 << 2); // beforeSwap + afterSwapReturnsDelta + address hookAddr = address(addrFlags); + uint256 feeFlags = HookFeeFlags.TAKES_SWAP_SURPLUS; + MockFeeClassifiedHook impl = new MockFeeClassifiedHook(feeFlags); + vm.etch(hookAddr, address(impl).code); + + PoolKey memory selfReportKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(hookAddr) + }); + poolManager.mockInitialize(selfReportKey); + + // Configure flag rule: TAKES_SWAP_SURPLUS -> family 3 + FlagRule[] memory rules = new FlagRule[](1); + rules[0] = FlagRule({requiredFlags: HookFeeFlags.TAKES_SWAP_SURPLUS, familyId: 3}); + + vm.startPrank(feeSetter); + policy.setFlagRules(rules); + policy.setFamilyDefault(3, FEE_200); + vm.stopPrank(); + + // Hook self-reports TAKES_SWAP_SURPLUS, rule maps to family 3 + assertEq(policy.computeFee(selfReportKey), FEE_200); + vm.snapshotGasLastCall("policy.computeFee - classified flag-rule self-report"); + } + + function test_computeFee_selfReport_governanceOverrideWins() public { + uint160 addrFlags = (1 << 7) | (1 << 2); + address hookAddr = address(addrFlags); + uint256 feeFlags = HookFeeFlags.TAKES_SWAP_SURPLUS; + MockFeeClassifiedHook impl = new MockFeeClassifiedHook(feeFlags); + vm.etch(hookAddr, address(impl).code); + + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(hookAddr) + }); + poolManager.mockInitialize(key); + + FlagRule[] memory rules = new FlagRule[](1); + rules[0] = FlagRule({requiredFlags: HookFeeFlags.TAKES_SWAP_SURPLUS, familyId: 3}); + + vm.startPrank(feeSetter); + policy.setFlagRules(rules); + policy.setFamilyDefault(3, FEE_200); // flag-rule family + policy.setFamilyDefault(5, FEE_500); // governance family + policy.setHookFamily(hookAddr, 5); // governance override + vm.stopPrank(); + + assertEq(policy.computeFee(key), FEE_500); + } + + function test_computeFee_selfReport_revertingHookFallsToDefault() public { + uint160 flags = (1 << 7) | (1 << 2); + address hookAddr = address(flags); + RevertingHook impl = new RevertingHook(); + vm.etch(hookAddr, address(impl).code); + + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(hookAddr) + }); + poolManager.mockInitialize(key); + + vm.prank(feeSetter); + policy.setDefaultFee(FEE_100); + + // Hook reverts -> treated as unclassified -> defaultFee + assertEq(policy.computeFee(key), FEE_100); + } + + function test_computeFee_selfReport_griefingHookDoesNotDOS() public { + uint160 flags = (1 << 7) | (1 << 2); + address hookAddr = address(flags); + GriefingHook impl = new GriefingHook(); + vm.etch(hookAddr, address(impl).code); + + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(hookAddr) + }); + poolManager.mockInitialize(key); + + vm.prank(feeSetter); + policy.setDefaultFee(FEE_100); + + // Gas-capped call should fail gracefully -> defaultFee + assertEq(policy.computeFee(key), FEE_100); + vm.snapshotGasLastCall("policy.computeFee - classified griefing hook -> defaultFee"); + } + + function test_computeFee_selfReport_boundsReturnDataCopy() public { + uint160 flags = (1 << 7) | (1 << 2); + address hookAddr = address(flags); + ReturnBombHook impl = new ReturnBombHook(); + vm.etch(hookAddr, address(impl).code); + + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(hookAddr) + }); + poolManager.mockInitialize(key); + + FlagRule[] memory rules = new FlagRule[](1); + rules[0] = FlagRule({requiredFlags: HookFeeFlags.TAKES_SWAP_SURPLUS, familyId: 1}); + + vm.startPrank(feeSetter); + policy.setFlagRules(rules); + policy.setFamilyDefault(1, FEE_300); + policy.setDefaultFee(FEE_100); + vm.stopPrank(); + + uint256 gasBefore = gasleft(); + uint24 fee = policy.computeFee(key); + uint256 gasUsed = gasBefore - gasleft(); + + assertEq(fee, FEE_300); + assertLt(gasUsed, 45_000); + } + + // ============ Policy: Configuration Functions ============ + + function test_setHookFamily_success() public { + address hook = address(uint160(1 << 2)); + vm.expectEmit(true, false, false, true, address(policy)); + emit IV4FeePolicy.HookFamilySet(hook, 1); + vm.prank(feeSetter); + policy.setHookFamily(hook, 1); + vm.snapshotGasLastCall("policy.setHookFamily"); + assertEq(policy.hookFamilyId(hook), 1); + } + + function test_setHookFamily_overwrite() public { + address hook = address(uint160(1 << 2)); + vm.startPrank(feeSetter); + policy.setHookFamily(hook, 1); + policy.setHookFamily(hook, 5); + vm.stopPrank(); + assertEq(policy.hookFamilyId(hook), 5); + } + + function test_setHookFamily_zeroUnclassifies() public { + address hook = address(uint160(1 << 2)); + vm.startPrank(feeSetter); + policy.setHookFamily(hook, 3); + policy.setHookFamily(hook, 0); + vm.stopPrank(); + assertEq(policy.hookFamilyId(hook), 0); + } + + function test_setHookFamily_revertsUnauthorized() public { + vm.prank(alice); + vm.expectRevert(IV4FeePolicy.Unauthorized.selector); + policy.setHookFamily(address(1), 1); + } + + function test_batchSetHookFamily_success() public { + address hook0 = address(uint160(1 << 2)); + address hook1 = address(uint160((1 << 7) | (1 << 2))); + + HookFamilyAssignment[] memory assignments = new HookFamilyAssignment[](2); + assignments[0] = HookFamilyAssignment({hook: hook0, familyId: 1}); + assignments[1] = HookFamilyAssignment({hook: hook1, familyId: 3}); + + vm.prank(feeSetter); + policy.batchSetHookFamily(assignments); + vm.snapshotGasLastCall("policy.batchSetHookFamily"); + + assertEq(policy.hookFamilyId(hook0), 1); + assertEq(policy.hookFamilyId(hook1), 3); + } + + function test_setDefaultFee_success() public { + vm.expectEmit(false, false, false, true, address(policy)); + emit IV4FeePolicy.DefaultFeeUpdated(FEE_100); + vm.prank(feeSetter); + policy.setDefaultFee(FEE_100); + } + + function test_setDefaultFee_revertsInvalidFee() public { + vm.prank(feeSetter); + vm.expectRevert(IV4FeePolicy.InvalidFeeValue.selector); + policy.setDefaultFee((2000 << 12) | 2000); + } + + function test_setFamilyDefault_success() public { + vm.expectEmit(true, false, false, true, address(policy)); + emit IV4FeePolicy.FamilyDefaultUpdated(1, FEE_300); + vm.prank(feeSetter); + policy.setFamilyDefault(1, FEE_300); + vm.snapshotGasLastCall("policy.setFamilyDefault"); + assertEq(policy.familyDefaults(1), FEE_300); + } + + function test_setFamilyDefault_revertsZeroFamily() public { + vm.prank(feeSetter); + vm.expectRevert(IV4FeePolicy.InvalidFamilyId.selector); + policy.setFamilyDefault(0, FEE_100); + } + + function test_setFamilyDefault_revertsNativeMathFamily() public { + uint8 nativeFamily = policy.NATIVE_MATH_FAMILY_ID(); + vm.prank(feeSetter); + vm.expectRevert(IV4FeePolicy.InvalidFamilyId.selector); + policy.setFamilyDefault(nativeFamily, FEE_100); + } + + function test_clearFamilyDefault_revertsNativeMathFamily() public { + uint8 nativeFamily = policy.NATIVE_MATH_FAMILY_ID(); + vm.prank(feeSetter); + vm.expectRevert(IV4FeePolicy.InvalidFamilyId.selector); + policy.clearFamilyDefault(nativeFamily); + } + + function test_setPairClassFee_success() public { + bytes32 ph = _pairHash(); + uint8 nativeFamily = policy.NATIVE_MATH_FAMILY_ID(); + vm.expectEmit(true, true, false, true, address(policy)); + emit IV4FeePolicy.PairClassFeeUpdated(ph, nativeFamily, FEE_200); + vm.prank(feeSetter); + policy.setPairClassFee(standardKey.currency0, standardKey.currency1, nativeFamily, FEE_200); + vm.snapshotGasLastCall("policy.setPairClassFee"); + assertEq(policy.pairClassFees(ph, nativeFamily), FEE_200); + } + + function test_setPairClassFee_revertsCurrenciesOutOfOrder() public { + vm.expectRevert(IV4FeePolicy.CurrenciesOutOfOrder.selector); + vm.prank(feeSetter); + policy.setPairClassFee(standardKey.currency1, standardKey.currency0, 1, FEE_200); + } + + function test_setPairClassFee_revertsInvalidFee() public { + vm.expectRevert(IV4FeePolicy.InvalidFeeValue.selector); + vm.prank(feeSetter); + policy.setPairClassFee(standardKey.currency0, standardKey.currency1, 1, (1001 << 12) | 500); + } + + function test_setPairClassFee_revertsFamilyZero() public { + // Regression (audit item 1): pairClassFees[pair][0] is never read by computeFee + // (family 0 returns defaultFee first), so a family-0 override would be dead storage. + vm.expectRevert(IV4FeePolicy.InvalidFamilyId.selector); + vm.prank(feeSetter); + policy.setPairClassFee(standardKey.currency0, standardKey.currency1, 0, FEE_200); + } + + function test_clearPairClassFee_revertsFamilyZero() public { + vm.expectRevert(IV4FeePolicy.InvalidFamilyId.selector); + vm.prank(feeSetter); + policy.clearPairClassFee(standardKey.currency0, standardKey.currency1, 0); + } + + function test_batchSetPairClassFee_success() public { + bytes32 ph = _pairHash(); + uint8 nativeFamily = policy.NATIVE_MATH_FAMILY_ID(); + + PairClassFeeAssignment[] memory assignments = new PairClassFeeAssignment[](2); + assignments[0] = PairClassFeeAssignment({ + currency0: standardKey.currency0, + currency1: standardKey.currency1, + familyId: nativeFamily, + feeValue: FEE_200 + }); + assignments[1] = PairClassFeeAssignment({ + currency0: standardKey.currency0, + currency1: standardKey.currency1, + familyId: 1, + feeValue: FEE_300 + }); + + vm.prank(feeSetter); + policy.batchSetPairClassFee(assignments); + vm.snapshotGasLastCall("policy.batchSetPairClassFee"); + + assertEq(policy.pairClassFees(ph, nativeFamily), FEE_200); + assertEq(policy.pairClassFees(ph, 1), FEE_300); + } + + function test_batchClearPairClassFee_success() public { + bytes32 ph = _pairHash(); + uint8 nativeFamily = policy.NATIVE_MATH_FAMILY_ID(); + + vm.startPrank(feeSetter); + policy.setPairClassFee(standardKey.currency0, standardKey.currency1, nativeFamily, FEE_500); + policy.setPairClassFee(standardKey.currency0, standardKey.currency1, 1, FEE_300); + + PairClassFeeClear[] memory clears = new PairClassFeeClear[](2); + clears[0] = PairClassFeeClear({ + currency0: standardKey.currency0, currency1: standardKey.currency1, familyId: nativeFamily + }); + clears[1] = PairClassFeeClear({ + currency0: standardKey.currency0, currency1: standardKey.currency1, familyId: 1 + }); + + policy.batchClearPairClassFee(clears); + vm.snapshotGasLastCall("policy.batchClearPairClassFee"); + vm.stopPrank(); + + assertEq(policy.pairClassFees(ph, nativeFamily), 0); + assertEq(policy.pairClassFees(ph, 1), 0); + } + + // ============ Policy: Sentinel Encoding ============ + + function test_sentinel_setZeroIsExplicitZero() public { + // setFamilyDefault(1, 0) stores sentinel -explicit zero fee, not "unset" + vm.startPrank(feeSetter); + policy.setFamilyDefault(1, FEE_200); + assertEq(policy.familyDefaults(1), FEE_200); + + policy.setFamilyDefault(1, 0); + assertEq(policy.familyDefaults(1), type(uint24).max); // sentinel in storage + vm.stopPrank(); + + // computeFee decodes sentinel to 0 -explicit zero, does not fall through + address customHook = address(uint160((1 << 7) | (1 << 2))); + PoolKey memory customKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(customHook) + }); + poolManager.mockInitialize(customKey); + + vm.startPrank(feeSetter); + policy.setHookFamily(customHook, 1); + policy.setDefaultFee(FEE_500); // would be used if familyDefault were unset + vm.stopPrank(); + + // Family 1 has explicit zero -> 0, NOT the defaultFee of FEE_500 + assertEq(policy.computeFee(customKey), 0); + } + + function test_sentinel_updateEventsDistinguishExplicitZeroAndClear() public { + bytes32 ph = _pairHash(); + + vm.startPrank(feeSetter); + + vm.expectEmit(false, false, false, true, address(policy)); + emit IV4FeePolicy.DefaultFeeUpdated(type(uint24).max); + policy.setDefaultFee(0); + + vm.expectEmit(false, false, false, true, address(policy)); + emit IV4FeePolicy.DefaultFeeUpdated(0); + policy.clearDefaultFee(); + + vm.expectEmit(true, false, false, true, address(policy)); + emit IV4FeePolicy.FamilyDefaultUpdated(1, type(uint24).max); + policy.setFamilyDefault(1, 0); + + vm.expectEmit(true, false, false, true, address(policy)); + emit IV4FeePolicy.FamilyDefaultUpdated(1, 0); + policy.clearFamilyDefault(1); + + uint8 nativeFamily = policy.NATIVE_MATH_FAMILY_ID(); + + vm.expectEmit(true, true, false, true, address(policy)); + emit IV4FeePolicy.PairClassFeeUpdated(ph, nativeFamily, type(uint24).max); + policy.setPairClassFee(standardKey.currency0, standardKey.currency1, nativeFamily, 0); + + vm.expectEmit(true, true, false, true, address(policy)); + emit IV4FeePolicy.PairClassFeeUpdated(ph, nativeFamily, 0); + policy.clearPairClassFee(standardKey.currency0, standardKey.currency1, nativeFamily); + + vm.stopPrank(); + } + + function test_sentinel_clearFallsThrough() public { + // clearFamilyDefault deletes storage -> falls through to defaultFee + address customHook = address(uint160((1 << 7) | (1 << 2))); + PoolKey memory customKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(customHook) + }); + poolManager.mockInitialize(customKey); + + vm.startPrank(feeSetter); + policy.setHookFamily(customHook, 1); + policy.setFamilyDefault(1, FEE_200); + policy.setDefaultFee(FEE_500); + vm.stopPrank(); + + assertEq(policy.computeFee(customKey), FEE_200); // familyDefault wins + + vm.prank(feeSetter); + policy.clearFamilyDefault(1); + + assertEq(policy.familyDefaults(1), 0); // storage is 0, not sentinel + assertEq(policy.computeFee(customKey), FEE_500); // falls through to defaultFee + } + + // ============ Policy: Clear Functions ============ + + function test_clearDefaultFee() public { + vm.startPrank(feeSetter); + policy.setDefaultFee(FEE_200); + assertEq(policy.defaultFee(), FEE_200); + + policy.clearDefaultFee(); + assertEq(policy.defaultFee(), 0); // storage deleted, not sentinel + vm.stopPrank(); + } + + function test_clearPairClassFee_fallsThroughToBuckets() public { + uint8 nativeFamily = policy.NATIVE_MATH_FAMILY_ID(); + vm.startPrank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + policy.setPairClassFee(standardKey.currency0, standardKey.currency1, nativeFamily, FEE_500); + vm.stopPrank(); + + assertEq(policy.computeFee(standardKey), FEE_500); // pair class fee wins + + vm.prank(feeSetter); + policy.clearPairClassFee(standardKey.currency0, standardKey.currency1, nativeFamily); + + assertEq(policy.pairClassFees(_pairHash(), nativeFamily), 0); // storage deleted + // Falls through to buckets: 3000 * 100_000 / 1_000_000 = 300 + assertEq(policy.computeFee(standardKey), FEE_300); + } + + function test_clearPairClassFee_revertsCurrenciesOutOfOrder() public { + vm.expectRevert(IV4FeePolicy.CurrenciesOutOfOrder.selector); + vm.prank(feeSetter); + policy.clearPairClassFee(standardKey.currency1, standardKey.currency0, 1); + } + + // ============ Integration: Full Waterfall ============ + + function test_integration_fullWaterfall() public { + address customHook = address(uint160((1 << 7) | (1 << 2))); + PoolKey memory customKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(customHook) + }); + poolManager.mockInitialize(customKey); + + vm.startPrank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + policy.setDefaultFee(FEE_100); + policy.setHookFamily(customHook, 1); + policy.setFamilyDefault(1, FEE_200); + policy.setPairClassFee( + standardKey.currency0, standardKey.currency1, policy.NATIVE_MATH_FAMILY_ID(), FEE_500 + ); + policy.setPairClassFee(customKey.currency0, customKey.currency1, 1, FEE_300); + vm.stopPrank(); + + // StandardKey -> StaticNativeMath -> native pair class fee -> FEE_500 + assertEq(adapter.getFee(standardKey), FEE_500); + + // CustomKey -> Classified -> pair class fee -> FEE_300 + assertEq(adapter.getFee(customKey), FEE_300); + + // Pool override beats everything + vm.prank(feeSetter); + adapter.setPoolOverride(standardKey.toId(), FEE_1000); + assertEq(adapter.getFee(standardKey), FEE_1000); + } + + function test_integration_triggerAndCollect() public { + Currency c = Currency.wrap(address(token0)); + uint256 amount = 100e18; + token0.mint(address(poolManager), amount); + poolManager.setProtocolFeesAccrued(c, amount); + + vm.prank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + + // Trigger fee update + adapter.triggerFeeUpdate(standardKey); + assertEq(poolManager.getProtocolFee(standardKey.toId()), FEE_300); + + // Collect fees + IV4FeeAdapter.CollectParams[] memory params = new IV4FeeAdapter.CollectParams[](1); + params[0] = IV4FeeAdapter.CollectParams({currency: c, amount: 0}); + adapter.collect(params); + assertEq(token0.balanceOf(tokenJar), amount); + } + + // ============ Edge Cases ============ + + function test_edge_maxProtocolFee() public { + vm.prank(feeSetter); + adapter.setPoolOverride(standardKey.toId(), FEE_1000); + assertEq(adapter.getFee(standardKey), FEE_1000); + } + + function test_edge_asymmetricFee() public { + uint24 asymmetric = (500 << 12) | 200; // 500 pips 1->0, 200 pips 0->1 + vm.prank(feeSetter); + adapter.setPoolOverride(standardKey.toId(), asymmetric); + + adapter.triggerFeeUpdate(standardKey); + assertEq(poolManager.getProtocolFee(standardKey.toId()), asymmetric); + } + + function test_edge_policySwap() public { + vm.prank(feeSetter); + policy.setFeeBuckets(_singleBucketSlope(TEST_BETA_PIPS)); + + assertEq(adapter.getFee(standardKey), FEE_300); + + // Deploy new policy with no multiplier configured (default 0) + vm.startPrank(owner); + V4FeePolicy newPolicy = new V4FeePolicy(IPoolManager(address(poolManager))); + newPolicy.setFeeSetter(feeSetter); + adapter.setPolicy(newPolicy); + vm.stopPrank(); + + // New policy has multiplier == 0 -> 0 + assertEq(adapter.getFee(standardKey), 0); + } + + // ============ Policy: Flag Rules Configuration ============ + + function test_setFlagRules_success() public { + FlagRule[] memory rules = new FlagRule[](2); + rules[0] = FlagRule({ + requiredFlags: HookFeeFlags.STABLE_PAIR | HookFeeFlags.TAKES_SWAP_SURPLUS, familyId: 3 + }); + rules[1] = FlagRule({requiredFlags: HookFeeFlags.TAKES_SWAP_SURPLUS, familyId: 2}); + + vm.expectEmit(false, false, false, true, address(policy)); + emit IV4FeePolicy.FlagRulesUpdated(2); + vm.prank(feeSetter); + policy.setFlagRules(rules); + vm.snapshotGasLastCall("policy.setFlagRules - two rules"); + + assertEq(policy.flagRulesLength(), 2); + (uint256 flags0, uint8 fam0) = policy.flagRules(0); + assertEq(flags0, HookFeeFlags.STABLE_PAIR | HookFeeFlags.TAKES_SWAP_SURPLUS); + assertEq(fam0, 3); + (uint256 flags1, uint8 fam1) = policy.flagRules(1); + assertEq(flags1, HookFeeFlags.TAKES_SWAP_SURPLUS); + assertEq(fam1, 2); + } + + function test_setFlagRules_replacesExisting() public { + FlagRule[] memory rules1 = new FlagRule[](2); + rules1[0] = FlagRule({requiredFlags: HookFeeFlags.STABLE_PAIR, familyId: 1}); + rules1[1] = FlagRule({requiredFlags: HookFeeFlags.TAKES_SWAP_SURPLUS, familyId: 2}); + + FlagRule[] memory rules2 = new FlagRule[](1); + rules2[0] = FlagRule({requiredFlags: HookFeeFlags.ORACLE_BASED, familyId: 5}); + + vm.startPrank(feeSetter); + policy.setFlagRules(rules1); + assertEq(policy.flagRulesLength(), 2); + policy.setFlagRules(rules2); + assertEq(policy.flagRulesLength(), 1); + vm.stopPrank(); + + (uint256 flags, uint8 fam) = policy.flagRules(0); + assertEq(flags, HookFeeFlags.ORACLE_BASED); + assertEq(fam, 5); + } + + function test_setFlagRules_revertsUnauthorized() public { + FlagRule[] memory rules = new FlagRule[](1); + rules[0] = FlagRule({requiredFlags: HookFeeFlags.STABLE_PAIR, familyId: 1}); + vm.prank(alice); + vm.expectRevert(IV4FeePolicy.Unauthorized.selector); + policy.setFlagRules(rules); + } + + function test_setFlagRules_revertsZeroRequiredFlags() public { + FlagRule[] memory rules = new FlagRule[](1); + rules[0] = FlagRule({requiredFlags: 0, familyId: 1}); + vm.prank(feeSetter); + vm.expectRevert(IV4FeePolicy.InvalidFlagRule.selector); + policy.setFlagRules(rules); + } + + function test_setFlagRules_revertsZeroFamilyId() public { + FlagRule[] memory rules = new FlagRule[](1); + rules[0] = FlagRule({requiredFlags: HookFeeFlags.STABLE_PAIR, familyId: 0}); + vm.prank(feeSetter); + vm.expectRevert(IV4FeePolicy.InvalidFlagRule.selector); + policy.setFlagRules(rules); + } + + function test_setFlagRules_revertsIfSpecificRuleFollowsBroadRule() public { + FlagRule[] memory rules = new FlagRule[](2); + rules[0] = FlagRule({requiredFlags: HookFeeFlags.TAKES_SWAP_SURPLUS, familyId: 2}); + rules[1] = FlagRule({ + requiredFlags: HookFeeFlags.STABLE_PAIR | HookFeeFlags.TAKES_SWAP_SURPLUS, familyId: 3 + }); + + vm.prank(feeSetter); + vm.expectRevert(IV4FeePolicy.FlagRulesNotSorted.selector); + policy.setFlagRules(rules); + } + + function test_setFlagRules_revertsTooManyRules() public { + FlagRule[] memory rules = new FlagRule[](33); + for (uint256 i; i < 33; ++i) { + rules[i] = FlagRule({requiredFlags: 1 << i, familyId: uint8(i + 1)}); + } + vm.prank(feeSetter); + vm.expectRevert(IV4FeePolicy.TooManyFlagRules.selector); + policy.setFlagRules(rules); + } + + function test_clearFlagRules() public { + FlagRule[] memory rules = new FlagRule[](1); + rules[0] = FlagRule({requiredFlags: HookFeeFlags.STABLE_PAIR, familyId: 1}); + vm.startPrank(feeSetter); + policy.setFlagRules(rules); + assertEq(policy.flagRulesLength(), 1); + + vm.expectEmit(false, false, false, true, address(policy)); + emit IV4FeePolicy.FlagRulesUpdated(0); + policy.clearFlagRules(); + assertEq(policy.flagRulesLength(), 0); + vm.stopPrank(); + } + + function test_clearFlagRules_revertsUnauthorized() public { + vm.prank(alice); + vm.expectRevert(IV4FeePolicy.Unauthorized.selector); + policy.clearFlagRules(); + } + + // ============ Policy: Flag-Based Classification ============ + + function test_flagRule_singleFlagMatch() public { + uint160 addrFlags = (1 << 7) | (1 << 2); // custom accounting + address hookAddr = address(addrFlags); + uint256 feeFlags = HookFeeFlags.TAKES_SWAP_SURPLUS; + MockFeeClassifiedHook impl = new MockFeeClassifiedHook(feeFlags); + vm.etch(hookAddr, address(impl).code); + + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(hookAddr) + }); + poolManager.mockInitialize(key); + + FlagRule[] memory rules = new FlagRule[](1); + rules[0] = FlagRule({requiredFlags: HookFeeFlags.TAKES_SWAP_SURPLUS, familyId: 2}); + + vm.startPrank(feeSetter); + policy.setFlagRules(rules); + policy.setFamilyDefault(2, FEE_300); + vm.stopPrank(); + + assertEq(policy.computeFee(key), FEE_300); + vm.snapshotGasLastCall("policy.computeFee - flag-rule single flag match"); + } + + function test_flagRule_multiFlagMatch() public { + uint160 addrFlags = (1 << 7) | (1 << 2); + address hookAddr = address(addrFlags); + uint256 feeFlags = HookFeeFlags.TAKES_SWAP_SURPLUS | HookFeeFlags.STABLE_PAIR; + MockFeeClassifiedHook impl = new MockFeeClassifiedHook(feeFlags); + vm.etch(hookAddr, address(impl).code); + + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(hookAddr) + }); + poolManager.mockInitialize(key); + + FlagRule[] memory rules = new FlagRule[](2); + // More specific rule first: both flags required + rules[0] = FlagRule({ + requiredFlags: HookFeeFlags.STABLE_PAIR | HookFeeFlags.TAKES_SWAP_SURPLUS, familyId: 3 + }); + // Less specific: only one flag + rules[1] = FlagRule({requiredFlags: HookFeeFlags.TAKES_SWAP_SURPLUS, familyId: 2}); + + vm.startPrank(feeSetter); + policy.setFlagRules(rules); + policy.setFamilyDefault(2, FEE_200); + policy.setFamilyDefault(3, FEE_500); + vm.stopPrank(); + + // Hook has both flags -> matches rule 0 (family 3) first + assertEq(policy.computeFee(key), FEE_500); + vm.snapshotGasLastCall("policy.computeFee - flag-rule multi-flag match"); + } + + function test_flagRule_priorityOrdering() public { + uint160 addrFlags = (1 << 7) | (1 << 2); + address hookAddr = address(addrFlags); + // Hook only has TAKES_SWAP_SURPLUS (not STABLE_PAIR) + uint256 feeFlags = HookFeeFlags.TAKES_SWAP_SURPLUS; + MockFeeClassifiedHook impl = new MockFeeClassifiedHook(feeFlags); + vm.etch(hookAddr, address(impl).code); + + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(hookAddr) + }); + poolManager.mockInitialize(key); + + FlagRule[] memory rules = new FlagRule[](2); + rules[0] = FlagRule({ + requiredFlags: HookFeeFlags.STABLE_PAIR | HookFeeFlags.TAKES_SWAP_SURPLUS, familyId: 3 + }); + rules[1] = FlagRule({requiredFlags: HookFeeFlags.TAKES_SWAP_SURPLUS, familyId: 2}); + + vm.startPrank(feeSetter); + policy.setFlagRules(rules); + policy.setFamilyDefault(2, FEE_200); + policy.setFamilyDefault(3, FEE_500); + vm.stopPrank(); + + // Hook lacks STABLE_PAIR -> skips rule 0, matches rule 1 (family 2) + assertEq(policy.computeFee(key), FEE_200); + } + + function test_flagRule_noMatchFallsToDefault() public { + uint160 addrFlags = (1 << 7) | (1 << 2); + address hookAddr = address(addrFlags); + // Hook reports ORACLE_BASED but no rules match that + uint256 feeFlags = HookFeeFlags.ORACLE_BASED; + MockFeeClassifiedHook impl = new MockFeeClassifiedHook(feeFlags); + vm.etch(hookAddr, address(impl).code); + + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(hookAddr) + }); + poolManager.mockInitialize(key); + + FlagRule[] memory rules = new FlagRule[](1); + rules[0] = FlagRule({requiredFlags: HookFeeFlags.STABLE_PAIR, familyId: 1}); + + vm.startPrank(feeSetter); + policy.setFlagRules(rules); + policy.setDefaultFee(FEE_100); + vm.stopPrank(); + + // No rule matches ORACLE_BASED -> falls through to defaultFee + assertEq(policy.computeFee(key), FEE_100); + } + + function test_flagRule_hookReportsZeroFlagsFallsToDefault() public { + uint160 addrFlags = (1 << 7) | (1 << 2); + address hookAddr = address(addrFlags); + // Hook reports zero flags + MockFeeClassifiedHook impl = new MockFeeClassifiedHook(0); + vm.etch(hookAddr, address(impl).code); + + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(hookAddr) + }); + poolManager.mockInitialize(key); + + FlagRule[] memory rules = new FlagRule[](1); + rules[0] = FlagRule({requiredFlags: HookFeeFlags.STABLE_PAIR, familyId: 1}); + + vm.startPrank(feeSetter); + policy.setFlagRules(rules); + policy.setFamilyDefault(1, FEE_500); + policy.setDefaultFee(FEE_100); + vm.stopPrank(); + + // Zero flags -> skips rule matching entirely -> defaultFee + assertEq(policy.computeFee(key), FEE_100); + } + + function test_flagRule_noRulesConfiguredFallsToDefault() public { + uint160 addrFlags = (1 << 7) | (1 << 2); + address hookAddr = address(addrFlags); + uint256 feeFlags = HookFeeFlags.TAKES_SWAP_SURPLUS; + MockFeeClassifiedHook impl = new MockFeeClassifiedHook(feeFlags); + vm.etch(hookAddr, address(impl).code); + + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(hookAddr) + }); + poolManager.mockInitialize(key); + + vm.prank(feeSetter); + policy.setDefaultFee(FEE_100); + + // No flag rules configured -> skips staticcall entirely -> defaultFee + assertEq(policy.computeFee(key), FEE_100); + } + + function test_flagRule_superset_matchesSubsetRule() public { + uint160 addrFlags = (1 << 7) | (1 << 2); + address hookAddr = address(addrFlags); + // Hook reports many flags + uint256 feeFlags = HookFeeFlags.TAKES_SWAP_SURPLUS | HookFeeFlags.STABLE_PAIR + | HookFeeFlags.ORACLE_BASED | HookFeeFlags.YIELD_BEARING; + MockFeeClassifiedHook impl = new MockFeeClassifiedHook(feeFlags); + vm.etch(hookAddr, address(impl).code); + + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(hookAddr) + }); + poolManager.mockInitialize(key); + + FlagRule[] memory rules = new FlagRule[](1); + // Rule only requires STABLE_PAIR + rules[0] = FlagRule({requiredFlags: HookFeeFlags.STABLE_PAIR, familyId: 4}); + + vm.startPrank(feeSetter); + policy.setFlagRules(rules); + policy.setFamilyDefault(4, FEE_200); + vm.stopPrank(); + + // Hook has STABLE_PAIR among other flags -> matches + assertEq(policy.computeFee(key), FEE_200); + } + + function test_flagRule_withPairClassFee() public { + uint160 addrFlags = (1 << 7) | (1 << 2); + address hookAddr = address(addrFlags); + uint256 feeFlags = HookFeeFlags.STABLE_PAIR | HookFeeFlags.TAKES_SWAP_SURPLUS; + MockFeeClassifiedHook impl = new MockFeeClassifiedHook(feeFlags); + vm.etch(hookAddr, address(impl).code); + + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(hookAddr) + }); + poolManager.mockInitialize(key); + + FlagRule[] memory rules = new FlagRule[](1); + rules[0] = FlagRule({ + requiredFlags: HookFeeFlags.STABLE_PAIR | HookFeeFlags.TAKES_SWAP_SURPLUS, familyId: 3 + }); + + vm.startPrank(feeSetter); + policy.setFlagRules(rules); + policy.setPairClassFee(key.currency0, key.currency1, 3, FEE_100); + vm.stopPrank(); + + assertEq(policy.computeFee(key), FEE_100); + } + + function test_flagRule_governanceOverrideTakesPriorityOverFlags() public { + uint160 addrFlags = (1 << 7) | (1 << 2); + address hookAddr = address(addrFlags); + uint256 feeFlags = HookFeeFlags.TAKES_SWAP_SURPLUS; + MockFeeClassifiedHook impl = new MockFeeClassifiedHook(feeFlags); + vm.etch(hookAddr, address(impl).code); + + PoolKey memory key = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 3000, + tickSpacing: 60, + hooks: IHooks(hookAddr) + }); + poolManager.mockInitialize(key); + + FlagRule[] memory rules = new FlagRule[](1); + rules[0] = FlagRule({requiredFlags: HookFeeFlags.TAKES_SWAP_SURPLUS, familyId: 2}); + + vm.startPrank(feeSetter); + policy.setFlagRules(rules); + policy.setFamilyDefault(2, FEE_200); // flag-rule would give this + policy.setHookFamily(hookAddr, 5); // governance override + policy.setFamilyDefault(5, FEE_500); // governance family fee + vm.stopPrank(); + + // Governance override (family 5) wins over flag-rule match (family 2) + assertEq(policy.computeFee(key), FEE_500); + } + + function test_flagRule_max32Rules() public { + FlagRule[] memory rules = new FlagRule[](32); + for (uint256 i; i < 32; ++i) { + rules[i] = FlagRule({requiredFlags: 1 << i, familyId: uint8(i + 1)}); + } + vm.prank(feeSetter); + policy.setFlagRules(rules); + vm.snapshotGasLastCall("policy.setFlagRules - 32 rules (max)"); + assertEq(policy.flagRulesLength(), 32); + } +} diff --git a/test/mocks/MockFeeClassifiedHook.sol b/test/mocks/MockFeeClassifiedHook.sol new file mode 100644 index 00000000..efe8b67e --- /dev/null +++ b/test/mocks/MockFeeClassifiedHook.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.26; + +import {IFeeClassifiedHook} from "../../src/interfaces/IFeeClassifiedHook.sol"; + +/// @notice Mock hook that self-reports behavioral flags via IFeeClassifiedHook. +contract MockFeeClassifiedHook is IFeeClassifiedHook { + uint256 public immutable flags; + + constructor(uint256 _flags) { + flags = _flags; + } + + function protocolFeeFlags() external view returns (uint256) { + return flags; + } +} + +/// @notice Mock hook that wastes all gas on protocolFeeFlags() to test griefing protection. +contract GriefingHook is IFeeClassifiedHook { + function protocolFeeFlags() external pure returns (uint256) { + while (true) {} + return 1; // unreachable + } +} + +/// @notice Mock hook that reverts on protocolFeeFlags(). +contract RevertingHook { + function protocolFeeFlags() external pure returns (uint256) { + revert("not classified"); + } +} + +/// @notice Mock hook that returns valid flags followed by excess returndata. +contract ReturnBombHook { + function protocolFeeFlags() external pure returns (uint256) { + assembly ("memory-safe") { + mstore(0, 1) + return(0, 0x10000) + } + } +} diff --git a/test/mocks/MockV4PoolManager.sol b/test/mocks/MockV4PoolManager.sol new file mode 100644 index 00000000..32c5e48a --- /dev/null +++ b/test/mocks/MockV4PoolManager.sol @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.26; + +import {ProtocolFees} from "v4-core/ProtocolFees.sol"; +import {Extsload} from "v4-core/Extsload.sol"; +import {Currency} from "v4-core/types/Currency.sol"; +import {PoolId, PoolIdLibrary} from "v4-core/types/PoolId.sol"; +import {Pool} from "v4-core/libraries/Pool.sol"; +import {PoolKey} from "v4-core/types/PoolKey.sol"; +import {Slot0} from "v4-core/types/Slot0.sol"; +import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; +import {IExtsload} from "v4-core/interfaces/IExtsload.sol"; +import {BalanceDelta} from "v4-core/types/BalanceDelta.sol"; +import {IHooks} from "v4-core/interfaces/IHooks.sol"; +import {ModifyLiquidityParams, SwapParams} from "v4-core/types/PoolOperation.sol"; + +/// @title MockV4PoolManager +/// @notice A minimal mock of the V4 PoolManager that supports ProtocolFees, extsload, and +/// enough IPoolManager surface for the V4FeeAdapter to function. Pools are stored in a +/// mapping at the same storage slot as the real PoolManager (slot 6 via POOLS_SLOT) because +/// ProtocolFees uses the same internal _getPool pattern. +/// @dev This mock does NOT implement the full IPoolManager interface. Unimplemented functions +/// revert with NotSupported(). +contract MockV4PoolManager is ProtocolFees, Extsload { + using PoolIdLibrary for PoolKey; + + error NotSupported(); + + uint160 constant SQRT_PRICE_1_1 = 79_228_162_514_264_337_593_543_950_336; + + /// @dev The pools mapping. In the real PoolManager, this is at storage slot 6 + /// (StateLibrary.POOLS_SLOT). Our storage layout must match for extsload to work: + /// Owned.owner (slot 0), ProtocolFees.protocolFeesAccrued (slot 1), + /// ProtocolFees.protocolFeeController (slot 2), and then we need 3 padding slots + /// before _pools lands at slot 6. We use explicit padding variables. + uint256 private _pad3; + uint256 private _pad4; + uint256 private _pad5; + mapping(PoolId => Pool.State) internal _pools; + + constructor(address initialOwner) ProtocolFees(initialOwner) {} + + /// @notice Initialize a pool with the standard 1:1 price. + /// @param poolKey The pool key to initialize. + function mockInitialize(PoolKey memory poolKey) external { + Pool.State storage state = _pools[poolKey.toId()]; + state.slot0 = Slot0.wrap(bytes32(0)).setSqrtPriceX96(SQRT_PRICE_1_1); + } + + /// @notice Initialize a pool with a specific LP fee set in Slot0. + /// @param poolKey The pool key to initialize. + /// @param lpFee The LP fee to set. + function mockInitializeWithLpFee(PoolKey memory poolKey, uint24 lpFee) external { + Pool.State storage state = _pools[poolKey.toId()]; + state.slot0 = Slot0.wrap(bytes32(0)).setSqrtPriceX96(SQRT_PRICE_1_1).setLpFee(lpFee); + } + + /// @notice Read the protocol fee from a pool's Slot0. + /// @param id The pool ID to query. + /// @return The protocol fee currently set on the pool. + function getProtocolFee(PoolId id) external view returns (uint24) { + return _pools[id].slot0.protocolFee(); + } + + /// @notice Set accrued protocol fees for a currency (for testing collection). + /// @param currency The currency to set fees for. + /// @param amount The amount of fees accrued. + function setProtocolFeesAccrued(Currency currency, uint256 amount) external { + protocolFeesAccrued[currency] = amount; + } + + // ─── ProtocolFees overrides ─── + + function _isUnlocked() internal pure override returns (bool) { + return false; + } + + function _getPool(PoolId id) internal view override returns (Pool.State storage) { + return _pools[id]; + } + + // ─── IPoolManager stubs (required for casting but not used in tests) ─────── + + function unlock(bytes calldata) external pure returns (bytes memory) { + revert NotSupported(); + } + + function initialize(PoolKey memory, uint160) external pure returns (int24) { + revert NotSupported(); + } + + function modifyLiquidity(PoolKey memory, ModifyLiquidityParams memory, bytes calldata) + external + pure + returns (BalanceDelta, BalanceDelta) + { + revert NotSupported(); + } + + function swap(PoolKey memory, SwapParams memory, bytes calldata) + external + pure + returns (BalanceDelta) + { + revert NotSupported(); + } + + function donate(PoolKey memory, uint256, uint256, bytes calldata) + external + pure + returns (BalanceDelta) + { + revert NotSupported(); + } + + function sync(Currency) external pure { + revert NotSupported(); + } + + function take(Currency, address, uint256) external pure { + revert NotSupported(); + } + + function settle() external payable returns (uint256) { + revert NotSupported(); + } + + function settleFor(address) external payable returns (uint256) { + revert NotSupported(); + } + + function clear(Currency, uint256) external pure { + revert NotSupported(); + } + + function mint(address, uint256, uint256) external pure { + revert NotSupported(); + } + + function burn(address, uint256, uint256) external pure { + revert NotSupported(); + } + + function updateDynamicLPFee(PoolKey memory, uint24) external pure { + revert NotSupported(); + } +} diff --git a/test/utils/HookFeeFlags.sol b/test/utils/HookFeeFlags.sol new file mode 100644 index 00000000..745238a1 --- /dev/null +++ b/test/utils/HookFeeFlags.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.26; + +/// @title HookFeeFlags (test scaffolding) +/// @notice Arbitrary named bits used by the flag-rule tests to construct mock hook +/// self-reports and `FlagRule.requiredFlags`. +/// @dev NOT shipped with the protocol. The policy ascribes no meaning to any bit — it +/// only checks that a rule's `requiredFlags` are a subset of the hook's returned +/// `uint256`. These constants exist purely so the tests read clearly; their specific +/// bit positions are irrelevant beyond being distinct. The real-world flag vocabulary +/// is documented in `docs/V4FeePolicy-governance-guide.md`, not enumerated onchain. +library HookFeeFlags { + uint256 internal constant TAKES_SWAP_SURPLUS = 1 << 0; + uint256 internal constant STABLE_PAIR = 1 << 4; + uint256 internal constant ORACLE_BASED = 1 << 5; + uint256 internal constant YIELD_BEARING = 1 << 9; +}