Skip to content

feat: collect market fees through the Doppler initializer - #1

Merged
paoloanzn merged 1 commit into
mainfrom
feat/doppler-beneficiary-fees
Aug 13, 2026
Merged

feat: collect market fees through the Doppler initializer#1
paoloanzn merged 1 commit into
mainfrom
feat/doppler-beneficiary-fees

Conversation

@paoloanzn

Copy link
Copy Markdown
Member

Doppler holds a pool's trading fees in its initializer and distributes them by share. collectFees(PoolId) harvests the outstanding amount and releases only the caller's portion, to a beneficiary registered when the pool was created. Uniswap V4 is a singleton, so a pool is a PoolKey rather than a contract, and its id is the hash of that key.

IDopplerHookInitializer declares that surface. Each signature was checked against DopplerHookInitializer and RehypeDopplerHookInitializer on Base Mainnet, and the seven-element getState tuple was decoded against the live contract:

getState(address)          0x1bab58f5
getShares(bytes32,address) 0x5ebb58fb
collectFees(bytes32)       0x817db73b

RIKRoyaltySplitter.collectPoolFees now takes the asset, reads the pool key from the initializer recorded at launch, derives the pool id, and credits the measured balance delta. Taking the initializer from storage rather than from the caller keeps collection from being aimed elsewhere.

Beneficiaries are fixed when a pool is created and cannot be added afterwards, so RIKLauncher reads getShares(poolId, splitter) after create and reverts with SplitterNotBeneficiary when it is zero. It also rejects a native numeraire, since releases are ERC20 transfers and the splitter cannot hold native value.

The mock reproduces FeesManager rather than approximating it — cumulative fees, per-beneficiary high-water marks, release to the caller — so the tests exercise the semantics Doppler actually has. MockAirlock delegates to the initializer the way the real Airlock delegates to poolInitializer.initialize. MarketSystem.invariant.t.sol absorbs the two invariants the splitter-only campaign held, which is removed.

The README documents the calling convention: the InitData layout, the beneficiary rules storeBeneficiaries enforces (ascending, summing to WAD, airlock.owner() with at least 5%), and the note that Airlock.create returns the asset in its pool slot for a V4 initializer.

286 tests pass, slither reports zero findings, lint and fmt are clean, and the deep soak runs 262,144 calls per system invariant.

Doppler holds a pool's trading fees in its initializer and distributes
them by share. `collectFees(PoolId)` harvests the outstanding amount and
releases only the caller's portion, to a beneficiary registered when the
pool was created. Uniswap V4 is a singleton, so a pool is a `PoolKey`
rather than a contract, and its id is the hash of that key.

`IDopplerHookInitializer` declares that surface. Each signature was
checked against `DopplerHookInitializer` and `RehypeDopplerHookInitializer`
on Base Mainnet, and the seven-element `getState` tuple was decoded
against the live contract:

    getState(address)          0x1bab58f5
    getShares(bytes32,address) 0x5ebb58fb
    collectFees(bytes32)       0x817db73b

`RIKRoyaltySplitter.collectPoolFees` now takes the asset, reads the pool
key from the initializer recorded at launch, derives the pool id, and
credits the measured balance delta. Taking the initializer from storage
rather than from the caller keeps collection from being aimed elsewhere.

Beneficiaries are fixed when a pool is created and cannot be added
afterwards, so `RIKLauncher` reads `getShares(poolId, splitter)` after
`create` and reverts with `SplitterNotBeneficiary` when it is zero. It
also rejects a native numeraire, since releases are ERC20 transfers and
the splitter cannot hold native value.

The mock reproduces `FeesManager` rather than approximating it —
cumulative fees, per-beneficiary high-water marks, release to the caller
— so the tests exercise the semantics Doppler actually has. `MockAirlock`
delegates to the initializer the way the real Airlock delegates to
`poolInitializer.initialize`. `MarketSystem.invariant.t.sol` absorbs the
two invariants the splitter-only campaign held, which is removed.

The README documents the calling convention: the `InitData` layout, the
beneficiary rules `storeBeneficiaries` enforces (ascending, summing to
WAD, `airlock.owner()` with at least 5%), and the note that
`Airlock.create` returns the asset in its `pool` slot for a V4
initializer.

286 tests pass, slither reports zero findings, lint and fmt are clean,
and the deep soak runs 262,144 calls per system invariant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@paoloanzn
paoloanzn merged commit fb13258 into main Aug 13, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant