The official extensions of the Frontier hook: fee calculators and observers
maintained by Frontier, tested against the real FactoryHook and deployed on Robinhood Chain.
To build your own extension, start from the
Frontier Extension Kit: it is the template
this repository builds on, and its README explains how extensions work, the rules they must follow
and how to test them. This repository pulls the kit as a submodule (lib/extension-kit), which
carries the published hook source (FrontierFun/factory-hook,
folder v1.1, the hook live on Robinhood Chain 4663).
A catalog of the extensions (here or served by the API) and a process for community submissions are upcoming; this repository holds Frontier's own extensions only for now.
git clone --recurse-submodules <this repo> && cd frontier-extensions
forge test # local: real hook on a local Uniswap v4 stack
FOUNDRY_PROFILE=fork forge test # live: against Robinhood Chain (in CI: the "Fork" workflow, by hand)| Extension | Roles | Status |
|---|---|---|
WthCorrector |
fee calculator + after-swap observer | not deployed |
TwapObserver |
after-swap observer | deployed, twap-observer/v1.0.0 |
One singleton bound on a pool in both roles: last fee calculator of the chain and last after-swap observer. After a user swap it has a partner executor close the price gap the swap opened against other venues, inside the same Uniswap unlock, and splits what the executor pays between the pool's LPs and the coin's fee recipient.
- Binding.
onRegisterCalculatorandonRegisterObserver, hook-gated through the kit'sHookGated, write-once per role. The config of each role isabi.encode(int24 tickSpacing, uint16 lpShareBps), the same for both roles: the pool key(ETH, coin, dynamic fee, tickSpacing, hook)rebuilt from it must hash to the pool id, the hook'sgetPoolStateprefix must name that coin as registered, andlpShareBpsmust lie betweenMIN_LP_SHARE_BPS(2500) and 10 000. There is no default: an empty or malformed config fails the deploy. List the corrector last in both lists: a calculator after it could reprice the legs, an observer before it that swaps would move the band. - Flow.
onAfterSwapopens a transient window carrying the band of the user's swap (its post-swap price and its pre-swap tick moved one tick inside) and callsexecutor.executeArbitrage(poolKey, address(0), ProfitSplit(this, 0, CREATOR_BPS, 0)), withCREATOR_BPSthe constant 8000, the sum the executor requires of the split's three shares (IWthArbitrageExecutor, selector0xd4641322).quoteFeeprices the legs the executor sends back through the pool: opposite to the user's swap, exact-input, with a price limit strictly inside the band, a leg pays the protocol floor only; any other leg pays the pool's normal fee.currentBand(poolId)exposes the band while the window is open. - Unwinding. The executor's own legs notify the corrector again; a transient self-lock returns
them at once. Around the call the corrector digests the PoolManager's nonzero-delta count, the
hook's and its own deltas on both currencies and the synced currency: a changed digest, a reverting
executor or a payment under
MIN_PAYMENT_WEIreverts the correction, which the hook swallows, and every leg unwinds with it. The user's swap always completes. - Payout. The corrector receives
CREATOR_BPS(8000) of the executor's realized profit, the executor's interface reserving the other 2000 bps, and takes nothing for the protocol. The payment is counted in the pool's quote currency, WETH and native ETH together (receiveaccepts ETH only during a correction); underMIN_PAYMENT_WEIthe correction reverts, whatever else the executor sent. The pool'slpShareBpsof it is donated to the pool's in-range liquidity in ETH (to the fee recipient instead when the pool has none, or when the swapper's router left a currency synced on the PoolManager), the rest goes to the coin'sgetFeeRecipient()in WETH; the coin creator sets that split at launch, LPs never under 25 %. A refused WETH transfer reverts the correction.CorrectionSettled(poolId, received, lpAmount, recipientAmount)records every split;ExecutorSetandTokensRecoveredthe owner's actions. - Gas. The correction runs inside the hook's 600k observer budget, shared with the pool's other
observers. With the test executor a full correction (one in-band leg through the hook, one leg on
a plain pool, payout with donation) costs about 292k: roughly 90k in the corrector, 103k for the
hook leg, 49k for the plain leg.
onAfterSwapreturns without calling the executor whengasleft()is underMIN_CORRECTION_GAS, and keepsTAIL_RESERVE(120k) back from the executor call for the snapshot check and the payout. - Owner levers.
setExecutor(address), callable by theBCTokenFactoryowner, with an event and no delay: the zero address pauses corrections on every bound pool.recoverERC20(token, to, amount), same gate, sends out stray tokens (the corrector holds nothing between transactions).MIN_CORRECTION_GAS(aboveTAIL_RESERVE) andMIN_PAYMENT_WEI(nonzero) are constructor immutables.
Tests: test/wth-corrector/ (the corrector through real swaps on the real
hook, a scripted executor arbitraging against a plain v4 pool, a hostile executor whose every
misbehaviour must stay contained, and the getPoolState prefix read checked against the typed
decode); test/fork/ checks that prefix on the live hook and simulates corrections on
a coin launched through the live factory.
One singleton bound on a pool as an after-swap observer. The hook's observe(poolId) gives the
truncated tick cumulative at the current timestamp, with no history; the observer stores those
readings so that any contract can read a time-weighted average tick without a keeper.
- Binding.
onRegisterObserver, hook-gated through the kit'sHookGated, once per pool (listing the observer twice fails the deploy). The config is empty, for the defaults (interval5 minutes,cardinality16), orabi.encode(uint32 interval, uint16 cardinality)withintervalbetween 1 minute and 1 day andcardinalitybetween 2 and 255; anything else fails the deploy. Subscribe withCALL_AFTER_SWAP.PoolBound(poolId, hook, interval, cardinality)records the binding. - Recording. On every swap, if at least
intervalseconds passed since the pool's newest observation,onAfterSwapstores(block.timestamp, tickCumulative)in the next slot of the pool's ring ofcardinalityslots, overwriting the oldest once full; otherwise it returns after one storage read.record(poolId)does the same for anyone, on a bound and graduated pool (right after graduation, or after a quiet stretch). No event on recording. - Reading.
consult(poolId, secondsAgo)returns(averageTick, span): the average truncated tick from the newest observation at leastsecondsAgoold to now, rounded toward negative infinity, and the seconds it actually covers.span >= secondsAgoalways and the answer is exact for that span (no interpolation); checkspanagainst your own tolerance. It revertsNotEnoughHistorywhen no stored observation is that old, and onsecondsAgo == 0.poolState,observationAt(index 0 the oldest) andlatestObservationexpose the ring. - Cardinality. The cardinality is the number of observations a pool's ring keeps, so with the
interval it sets how far back
consultcan reach. Anyone may grow it withincreaseCardinality(poolId, cardinalityNext), up to 255, and pays for the new storage: the new slots are written with a placeholder at once, so later recordings into them cost an overwrite, not a fresh slot. It never shrinks, and the interval never changes. The growth is pending (cardinalityNextinpoolState) until the ring next writes its current last slot; from there it continues into the new slots, so no kept observation is lost or reordered, and the extra history builds up one recording at a time. A placeholder is never read.CardinalityIncreased(poolId, old, new)records every growth. - Gas. Measured on the observer alone, its account cold: about 4.1k when nothing is due, 29.6k
when it writes a fresh slot, 9.7k when it writes a slot a growth pre-wrote, about 10.9k for
recordoverwriting a slot of a full ring, 21.5k forconsulton a full 16-slot ring and 33.1k on a full 255-slot ring.increaseCardinalitycosts about 22.7k per added slot. Inside a real swap, the hook's notification included, a bound pool pays about 9.5k more per swap when nothing is due and 37.5k when a fresh slot is written. Since anyone may grow a pool's ring and it never shrinks, a reader under a gas cap (a fee calculator underCALC_GAS_STIPEND, an observer under the shared budget) budgetsconsultat its 255-slot cost, whatever the pool's cardinality today. - Limits. It knows only what the hook's truncated tick knows: that tick moves at most
MAX_ABS_TICK_MOVE(9116) per second (the clamp keys onblock.timestamp, which several blocks share on Robinhood Chain) from the tick the second opened at, so a larger jump reaches the average over several seconds. 9116 ticks is a factor of 2.49 on the price, so a short window stays movable: a tick held off bydfortseconds shifts an average overSseconds byd * t / S. History reaches back(cardinality - 1) * intervalseconds at least once the ring is full (the newest observation can be brand new), more when swaps are sparse; after a growth, only once the ring has filled the new slots. Askconsultfor at most that, since anyone recording on schedule holds the history at exactly that reach. Nothing is recorded while nobody swaps, which is harmless since the price does not move then;recordfills the gap when a consumer needs it. When swaps resume after a quiet stretch, a read reaches back to the observation taken before the stretch until the fresh one issecondsAgoold, so the average catches up withinsecondsAgo. The cumulative comes fromIFactoryHook.observe, not from theIExtensionHostsurface.
Tests: test/twap-observer/ (binding and refused configs, recording through
real swaps and record, consult against hand computations, binary search against a linear scan on
fuzzed rings that grew, ring growth at every stage of a lap, an invariant suite of random swaps,
time jumps and growths, and gas ceilings).
You need Foundry. Everything else comes from the submodules.
forge build
forge test
FOUNDRY_PROFILE=ci forge snapshot --check # gas regressions, as CI runs itImport paths: kit/… for the kit (kit/HookGated.sol), frontier/… for the hook's interfaces,
frontier-test/… for its test harness, contracts/… for this repository.
Each extension is tagged on its own: twap-observer/v1.0.0, wth-corrector/v1.0.0, and so on.
Deployed addresses are listed below, one line per extension and chain. Deploy scripts live in
script/ and pick the chain's BCTokenFactory by chain id.
| Extension | Chain | Address | Tag |
|---|---|---|---|
TwapObserver |
Robinhood Chain (4663) | 0xC2f8516564D25F76f6E838A1798682D6B9EEc158 |
twap-observer/v1.0.0 |
TwapObserver |
Arbitrum Sepolia (421614) | 0xdFDc85f355bB593b9092aa308AA2A8b12a455F6e |
twap-observer/v1.0.0 |
Both are verified on Sourcify.
MIT (LICENSE).