Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
531aeb0
feat: turn onSwap into onAfterSwap, add onBeforeSwap
clemlak Feb 12, 2026
73256dd
Merge branch 'main' into feat/rehype-decaying-fee
clemlak Mar 2, 2026
d7b6bf0
feat: add decaying fee to RehypeHook
clemlak Mar 2, 2026
e994244
fix: reentrancy in collectFees
clemlak Mar 4, 2026
e930f43
chore: remove check for impossible case
clemlak Mar 4, 2026
964aa64
fix: roll back Decaying fee changes to the Rehype Migrator
clemlak Mar 4, 2026
29d5b2b
chore: remove unused code
clemlak Mar 5, 2026
9ebb95e
chore: remove unused code, read LP fee from PoolManager
clemlak Mar 5, 2026
1174a10
chore: remove unused code, add MigratorInitData
clemlak Mar 5, 2026
b9d131c
test: update various tests
clemlak Mar 5, 2026
636f5f5
fix: use custom fee again for Rehype Doppler Hook Migrator
clemlak Mar 5, 2026
4995269
test: add more tests
clemlak Mar 5, 2026
9cbe6cb
chore: rename BaseDopplerHook to BaseDopplerHookInitializer
clemlak Mar 5, 2026
38392d9
chore: roll back change
clemlak Mar 5, 2026
b75e29c
chore: remove unused field
clemlak Mar 5, 2026
f1e7187
test: add more tests
clemlak Mar 5, 2026
4b89717
chore: fmt
clemlak Mar 5, 2026
5a97241
chore: update DopplerHookInitializer docs
clemlak Mar 6, 2026
47ea129
build: update DopplerHookInitializer mining script
clemlak Mar 6, 2026
e03252e
chore: update docs, deploying scripts
clemlak Mar 6, 2026
33ff1da
fix: stack too deep (no idea why)
clemlak Mar 6, 2026
e51993e
feat: add BaseMinimalHook
clemlak Mar 6, 2026
8f58d2a
test: add new tests
clemlak Mar 9, 2026
28aaccf
build: deploy contracts
clemlak Mar 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

369 changes: 160 additions & 209 deletions broadcast/multi/DeployDopplerHookInitializer.s.sol-latest/run.json

Large diffs are not rendered by default.

477 changes: 477 additions & 0 deletions broadcast/multi/DeployRehypeDopplerHook.s.sol-1772914563919/run.json

Large diffs are not rendered by default.

314 changes: 157 additions & 157 deletions broadcast/multi/DeployRehypeDopplerHook.s.sol-latest/run.json

Large diffs are not rendered by default.

Large diffs are not rendered by default.

314 changes: 157 additions & 157 deletions broadcast/multi/DeployRehypeDopplerHookMigrator.s.sol-latest/run.json

Large diffs are not rendered by default.

30 changes: 15 additions & 15 deletions deployments.config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@ uniswap_v4_migrator = "0x0820A4D0173C17Ece283f7bDaAF0f8876eB205f5"
uniswap_v4_migrator_hook = "0x4053D4fa966cbdCC20Ec62070aC8814De8bEE500"
uniswap_v4_initializer = "0x53b4c21a6Cb61D64F636ABBfa6E8E90E6558e8ad"
doppler_deployer = "0xb35469ee64A87Afd19B31615094fE3962d73e421"
doppler_hook_initializer = "0xAA096F558f3d4c9226De77E7Cc05f18E180B2544"
rehype_doppler_hook = "0x3Ec4798A9B11e8243A8Db99687f7A23597B96623"
doppler_hook_initializer = "0x8d2d06Fd273cd19e1cA6d6641e93137F1b0F25C4"
rehype_doppler_hook = "0x034426C7Dd394608b7349bD5883329954da2580C"
clone_derc20_v2_votes_factory = "0x16F5ACB64F4FA17296E942C51d3395aDC318f9e1"
top_up_distributor = "0x435312320C0330B1999746753551CdFbD83aD814"
streamable_fees_locker_v2 = "0xcE3212e6536F33cD6fbFEE265224131353Ca3D47"
doppler_hook_migrator = "0x1E40b0875DDa35f41E15cFB475403859B8c860c4"
rehype_doppler_hook_migrator = "0xC3c9F4cFD1dC0A7837cC4b202B3455B4156a8005"
rehype_doppler_hook_migrator = "0xB78eBFa2c0689798b78da4D2D40FfFD1e732C475"
governance_factory = "0x9F309D79BEe3E8b2f56FaCF74b7195Df176c8F61"

[11155111]
Expand Down Expand Up @@ -61,15 +61,15 @@ uniswap_v4_migrator = "0x0820A4D0173C17Ece283f7bDaAF0f8876eB205f5"
uniswap_v4_migrator_hook = "0x4053D4fa966cbdCC20Ec62070aC8814De8bEE500"
uniswap_v4_initializer = "0x53b4c21a6Cb61D64F636ABBfa6E8E90E6558e8ad"
doppler_deployer = "0xb35469ee64A87Afd19B31615094fE3962d73e421"
doppler_hook_initializer = "0xAA096F558f3d4c9226De77E7Cc05f18E180B2544"
rehype_doppler_hook = "0x3Ec4798A9B11e8243A8Db99687f7A23597B96623"
doppler_hook_initializer = "0x8d2d06Fd273cd19e1cA6d6641e93137F1b0F25C4"
rehype_doppler_hook = "0x034426C7Dd394608b7349bD5883329954da2580C"
clone_derc20_v2_votes_factory = "0x16F5ACB64F4FA17296E942C51d3395aDC318f9e1"
decay_multicurve_initializer_hook = "0xbB7784A4d481184283Ed89619A3e3ed143e1Adc0"
decay_multicurve_initializer = "0xD59cE43E53D69F190E15d9822Fb4540dCcc91178"
top_up_distributor = "0x435312320C0330B1999746753551CdFbD83aD814"
streamable_fees_locker_v2 = "0xcE3212e6536F33cD6fbFEE265224131353Ca3D47"
doppler_hook_migrator = "0x1E40b0875DDa35f41E15cFB475403859B8c860c4"
rehype_doppler_hook_migrator = "0xC3c9F4cFD1dC0A7837cC4b202B3455B4156a8005"
rehype_doppler_hook_migrator = "0xB78eBFa2c0689798b78da4D2D40FfFD1e732C475"
governance_factory = "0x9F309D79BEe3E8b2f56FaCF74b7195Df176c8F61"

[8453]
Expand All @@ -87,8 +87,8 @@ token_factory_80 = "0xf0B5141dD9096254B2ca624dff26024f46087229"
uniswap_v4_pool_manager = "0x498581ff718922c3f8e6a244956af099b2652b2b"
doppler_deployer = "0xb35469ee64A87Afd19B31615094fE3962d73e421"
uniswap_v4_initializer = "0x53b4c21a6Cb61D64F636ABBfa6E8E90E6558e8ad"
doppler_hook_initializer = "0xAA096F558f3d4c9226De77E7Cc05f18E180B2544"
rehype_doppler_hook = "0x3Ec4798A9B11e8243A8Db99687f7A23597B96623"
doppler_hook_initializer = "0x8d2d06Fd273cd19e1cA6d6641e93137F1b0F25C4"
rehype_doppler_hook = "0x034426C7Dd394608b7349bD5883329954da2580C"
no_op_governance_factory = "0x3ad727ee0fbbb8ee0920933fdb96f23fd56f1299"
no_op_migrator = "0x6ddfed58d238ca3195e49d8ac3d4cea6386e5c33"
clone_derc20_v2_votes_factory = "0x16F5ACB64F4FA17296E942C51d3395aDC318f9e1"
Expand All @@ -97,7 +97,7 @@ decay_multicurve_initializer = "0xD59cE43E53D69F190E15d9822Fb4540dCcc91178"
top_up_distributor = "0x435312320C0330B1999746753551CdFbD83aD814"
streamable_fees_locker_v2 = "0xcE3212e6536F33cD6fbFEE265224131353Ca3D47"
doppler_hook_migrator = "0x1E40b0875DDa35f41E15cFB475403859B8c860c4"
rehype_doppler_hook_migrator = "0xC3c9F4cFD1dC0A7837cC4b202B3455B4156a8005"
rehype_doppler_hook_migrator = "0xB78eBFa2c0689798b78da4D2D40FfFD1e732C475"

[130]
endpoint_url = "${UNICHAIN_MAINNET_RPC_URL}"
Expand Down Expand Up @@ -128,16 +128,16 @@ token_factory_80 = "0xf0B5141dD9096254B2ca624dff26024f46087229"
uniswap_v4_pool_manager = "0x188d586ddcf52439676ca21a244753fa19f9ea8e"
doppler_deployer = "0xb35469ee64A87Afd19B31615094fE3962d73e421"
uniswap_v4_initializer = "0x53b4c21a6Cb61D64F636ABBfa6E8E90E6558e8ad"
doppler_hook_initializer = "0xAA096F558f3d4c9226De77E7Cc05f18E180B2544"
rehype_doppler_hook = "0x3Ec4798A9B11e8243A8Db99687f7A23597B96623"
doppler_hook_initializer = "0x8d2d06Fd273cd19e1cA6d6641e93137F1b0F25C4"
rehype_doppler_hook = "0x034426C7Dd394608b7349bD5883329954da2580C"
no_op_governance_factory = "0xb4dee32eb70a5e55f3d2d861f49fb3d79f7a14d9"
no_op_migrator = "0x5f3ba43d44375286296cb85f1ea2ebfa25dde731"
clone_derc20_v2_votes_factory = "0x16F5ACB64F4FA17296E942C51d3395aDC318f9e1"
top_up_distributor = "0x435312320C0330B1999746753551CdFbD83aD814"
airlock_multisig = "0x21E2ce70511e4FE542a97708e89520471DAa7A66"
streamable_fees_locker_v2 = "0xcE3212e6536F33cD6fbFEE265224131353Ca3D47"
doppler_hook_migrator = "0x1E40b0875DDa35f41E15cFB475403859B8c860c4"
rehype_doppler_hook_migrator = "0xC3c9F4cFD1dC0A7837cC4b202B3455B4156a8005"
rehype_doppler_hook_migrator = "0xB78eBFa2c0689798b78da4D2D40FfFD1e732C475"

[4326]
endpoint_url = "${MEGAETH_MAINNET_RPC_URL}"
Expand Down Expand Up @@ -172,8 +172,8 @@ doppler_deployer = "0xb35469ee64A87Afd19B31615094fE3962d73e421"
uniswap_v4_initializer = "0x53b4c21a6Cb61D64F636ABBfa6E8E90E6558e8ad"
uniswap_v4_scheduled_multicurve_hook = "0xc6a562cb5CbFA29BCB1bDCCF903b8B8f2E4A2DC0"
uniswap_v4_scheduled_multicurve_initializer = "0xF84378C9F39e0FF267f3101c88773359c5393876"
doppler_hook_initializer = "0xAA096F558f3d4c9226De77E7Cc05f18E180B2544"
rehype_doppler_hook = "0x3Ec4798A9B11e8243A8Db99687f7A23597B96623"
doppler_hook_initializer = "0x8d2d06Fd273cd19e1cA6d6641e93137F1b0F25C4"
rehype_doppler_hook = "0x034426C7Dd394608b7349bD5883329954da2580C"
no_op_governance_factory = "0x7bd798fafc99a3b17e261f8308a8c11b56935ea1"
no_op_migrator = "0xf11066abbd329ac4bba39455340539322c222eb0"
uniswap_v2_migrator = "0x04a898f3722c38f9def707bd17dc78920efa977c"
Expand All @@ -191,7 +191,7 @@ decay_multicurve_initializer_hook = "0xbB7784A4d481184283Ed89619A3e3ed143e1Adc0"
decay_multicurve_initializer = "0xD59cE43E53D69F190E15d9822Fb4540dCcc91178"
streamable_fees_locker_v2 = "0xcE3212e6536F33cD6fbFEE265224131353Ca3D47"
doppler_hook_migrator = "0x1E40b0875DDa35f41E15cFB475403859B8c860c4"
rehype_doppler_hook_migrator = "0xC3c9F4cFD1dC0A7837cC4b202B3455B4156a8005"
rehype_doppler_hook_migrator = "0xB78eBFa2c0689798b78da4D2D40FfFD1e732C475"

[1301]
endpoint_url = "${UNICHAIN_SEPOLIA_RPC_URL}"
Expand Down
74 changes: 55 additions & 19 deletions docs/DopplerHookInitializer.md
Original file line number Diff line number Diff line change
@@ -1,29 +1,65 @@
# Doppler Hooks
# DopplerHookInitializer

## Overview

Doppler Hooks are a set of callback functions that can be called during the lifecycle of "locked" pools initialized by the `DopplerHookInitializer` contract. Three main events will trigger these hooks:
`DopplerHookInitializer` is a `PoolInitializer` for Uniswap v4 multicurve pools that also acts as the pool's hook contract. In addition to placing and managing liquidity, it can forward lifecycle events to an optional external Doppler Hook contract associated with the pool.

- `initialization`: when a new pool is created
- `swap`: when a swap occurs in the pool
- `graduation`: when the pool reaches a certain price maturity
The external Doppler Hook is configured per asset and can be set during `initialize()` or later via `setDopplerHook()`.

Additionally, pools associated with a Doppler Hook can have their LP fee updated by the associated timelock governance contract or a delegated address.
## Pool Lifecycle

A couple of things to note:
Pools managed by the initializer move through these statuses:

- A pool initialized without a Doppler Hook can opt-in to use one later via the `setHook` function
- A pool initialized with a Doppler Hook can opt-out of using it later by setting the hook address to `address(0)`
- A pool can change its associated Doppler Hook to a different one at any time via the `setHook` function
- Doppler Hooks are approved by the protocol multisig
- A pool without a Doppler Hook cannot be initialized with a dynamic LP fee
| Status | Description |
| --- | --- |
| `Uninitialized` | Default state before `initialize()` is called |
| `Initialized` | Pool exists and liquidity is live, but there are no locked beneficiaries |
| `Locked` | Pool exists and beneficiary accounting is active |
| `Graduated` | Pool has reached its graduation condition and `graduate()` has executed |
| `Exited` | Liquidity has been removed through `exitLiquidity()` |

## Implementation
Only `Locked` pools can change their associated Doppler Hook or graduate.

Here are the different callback functions available for the Doppler Hooks, note that they can be implemented selectively based on the use case:
## Doppler Hook Callbacks

| Callback Function | Triggered By |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `onInitialization(address asset, PoolKey calldata key, bytes calldata data` | - `initialize()` if a `dopplerHook` address is set in the `InitData`<br />- `setDopplerHook()` if a Doppler Hook is set after the pool initialization |
| `onSwap(address sender, PoolKey calldata key, IPoolManager.SwapParams calldata params,BalanceDelta delta, bytes calldata data) returns (Currency feeCurrency, int128 hookDelta)` | `afterSwap` before each swap happening in the Uniswap V4 pool |
| `onGraduation(address asset, PoolKey calldata key, bytes calldata data)` | `graduate` if the graduation conditions are met (e.g. `farTick` reached) |
The initializer can forward four callback types to the configured external Doppler Hook. Each callback is enabled independently through `setDopplerHookState()`.

| Callback | Trigger |
| --- | --- |
| `onInitialization(address asset, PoolKey calldata key, bytes calldata data)` | Called during `initialize()` when a hook is already configured, or during `setDopplerHook()` when a new hook is attached to an existing locked pool |
| `onBeforeSwap(address sender, PoolKey calldata key, IPoolManager.SwapParams calldata params, bytes calldata data) returns (uint24 lpFeeOverride)` | Called from the initializer's `beforeSwap` hook before every swap when `ON_BEFORE_SWAP_FLAG` is enabled |
| `onAfterSwap(address sender, PoolKey calldata key, IPoolManager.SwapParams calldata params, BalanceDelta delta, bytes calldata data) returns (Currency feeCurrency, int128 hookDelta)` | Called from the initializer's `afterSwap` hook after every swap when `ON_AFTER_SWAP_FLAG` is enabled |
| `onGraduation(address asset, PoolKey calldata key, bytes calldata data)` | Called by `graduate()` when the pool reaches its graduation condition and `ON_GRADUATION_FLAG` is enabled |

## Hook Registration

External Doppler Hooks must be approved by the Airlock owner through `setDopplerHookState(address[] dopplerHooks, uint256[] flags)`.

The available flags are defined in `BaseDopplerHookInitializer.sol`:

- `ON_INITIALIZATION_FLAG`
- `ON_BEFORE_SWAP_FLAG`
- `ON_AFTER_SWAP_FLAG`
- `ON_GRADUATION_FLAG`
- `REQUIRES_DYNAMIC_LP_FEE_FLAG`

Key behaviors:

- A pool initialized without a Doppler Hook can later attach one with `setDopplerHook()`
- A pool can opt out by setting the hook to `address(0)`
- A pool can swap from one approved hook to another while it is `Locked`
- `setDopplerHook()` can only be called by the asset timelock or its delegated authority

## Dynamic LP Fees

When a pool is initialized with a Doppler Hook, the pool itself is created as a dynamic-fee Uniswap v4 pool and the initializer seeds the initial LP fee with the `fee` field from `InitData`.

If `ON_BEFORE_SWAP_FLAG` is enabled, the external hook's `onBeforeSwap()` callback may return an LP fee override for the current swap. Uniswap v4 only honors that override for dynamic-fee pools.

That means:

- A pool created without a Doppler Hook keeps the fixed fee from `InitData.fee`
- Adding a hook later with `setDopplerHook()` does not retroactively convert that pool into a dynamic-fee pool
- On a fixed-fee pool, `onBeforeSwap()` can still be called, but any returned LP fee override is ignored by Uniswap v4

The external Doppler Hook associated with a locked pool can also update the pool's dynamic LP fee directly through `updateDynamicLPFee()`, subject to the initializer's max LP fee cap.
125 changes: 125 additions & 0 deletions docs/RehypeDopplerHookInitializer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# RehypeDopplerHookInitializer

## Overview

This page documents the initializer-side `RehypeDopplerHook` contract, which is the Doppler Hook designed to be attached to pools created by [`DopplerHookInitializer`](./DopplerHookInitializer.md).

`RehypeDopplerHook` implements two pieces of hook logic:

- `onInitialization`, which stores per-pool fee configuration
- `onAfterSwap`, which collects and routes Rehype fees after swaps

It does not implement custom `onBeforeSwap` or `onGraduation` behavior. In practice, the expected hook registration is:

- `ON_INITIALIZATION_FLAG`
- `ON_AFTER_SWAP_FLAG`

## What This Hook Does

At a high level, `RehypeDopplerHook` adds a post-swap fee layer on top of a Doppler pool. For each supported pool, it can:

- charge a Rehype hook fee on swaps
- decay that hook fee from `startFee` to `endFee` over time
- split collected fees across buybacks, beneficiary accounting, and LP reinvestment
- carve out a fixed 5% share of the raw hook fee for the Airlock owner

Important: this fee schedule controls the Rehype hook fee collected in `onAfterSwap`. It does not update the Uniswap v4 LP fee for the pool.

## Initialization Data

On `onInitialization`, the hook decodes `RehypeTypes.InitData` and stores:

| Field | Meaning |
| --- | --- |
| `numeraire` | Quote token used by the pool |
| `buybackDst` | Recipient for direct buybacks and claimed beneficiary fees |
| `startFee` | Hook fee at schedule start, in millionths |
| `endFee` | Terminal hook fee after decay completes, in millionths |
| `durationSeconds` | Linear decay duration |
| `startingTime` | Fee schedule start time |
| `feeRoutingMode` | Whether buyback-designated fees are transferred immediately or routed into beneficiary accounting |
| `feeDistributionInfo` | Fee split matrix for asset-side and numeraire-side fees |

The hook validates the configuration as follows:

- both `startFee` and `endFee` must be `<= MAX_SWAP_FEE`
- `startFee` must be `>= endFee`
- if `startFee > endFee`, `durationSeconds` must be non-zero
- each row of `feeDistributionInfo` must sum to `WAD`
- `startingTime` is normalized to `block.timestamp` when it is `0` or already in the past

It also initializes a full-range LP position record for later reinvestment.

## Fee Schedule

The hook stores a `FeeSchedule` per pool:

- before `startingTime`, the active fee is `startFee`
- after `startingTime`, the fee decays linearly toward `endFee`
- once the full duration has elapsed, the fee stays at `endFee`
- for flat schedules (`startFee == endFee`), the fee never changes
- `lastFee` caches the last applied value and `FeeUpdated` is emitted only when the fee decreases

This makes the fee schedule lazy: it is evaluated when swaps happen, not by a background process.

## Swap Behavior

All fee logic runs in `onAfterSwap`.

For each external swap:

1. The hook ignores internal self-swaps so it does not charge itself during its own rebalance or buyback operations.
2. It computes the current Rehype fee from the schedule.
3. It charges that fee in the swap's unspecified token.
4. It takes 5% of the raw fee for the Airlock owner.
5. It accumulates the remaining 95% into per-pool fee balances.

If both accumulated fee balances are still below `EPSILON`, the hook stops there and waits for more fees to build up.

Once enough fees have accumulated, the hook routes them according to `feeDistributionInfo`:

- asset fees can be sent directly as asset buyback, swapped into numeraire buyback, accrued as beneficiary fees, or allocated to LP reinvestment
- numeraire fees can be swapped into asset buyback, sent directly as numeraire buyback, accrued as beneficiary fees, or allocated to LP reinvestment

## Fee Routing Modes

`RehypeDopplerHook` supports two routing modes:

| Mode | Behavior |
| --- | --- |
| `DirectBuyback` | Buyback-designated outputs are transferred immediately to `buybackDst` |
| `RouteToBeneficiaryFees` | Buyback-designated outputs are added to beneficiary fee accounting instead of being transferred immediately |

Note that Rehype's `beneficiaryFees` are internal hook accounting and are ultimately claimed to `buybackDst`. They are separate from the locked pool beneficiary shares managed by `DopplerHookInitializer`.

## LP Reinvestment

The LP-designated portions of collected fees are not simply parked. The hook:

- computes the token imbalance against a full-range LP position
- optionally performs an internal swap to rebalance the fee inventory
- adds the balanced amounts back into a full-range position for the pool

Any leftovers after buybacks and LP reinvestment are rolled into `beneficiaryFees0` and `beneficiaryFees1`.

## Claims

The hook exposes two claim paths:

- `collectFees(asset)`: transfers accumulated `beneficiaryFees0/1` to `buybackDst`
- `claimAirlockOwnerFees(asset)`: transfers accumulated `airlockOwnerFees0/1` to the current Airlock owner

Both functions are `nonReentrant`.

## Readable State

The main per-pool views are:

- `getPoolInfo(poolId)`
- `getFeeDistributionInfo(poolId)`
- `getFeeRoutingMode(poolId)`
- `getFeeSchedule(poolId)`
- `getHookFees(poolId)`
- `getPosition(poolId)`

Together they describe the configured fee schedule, the routing mode, the current fee balances, and the reinvested LP position state.
Loading
Loading