This code is unaudited and has never held value. It is deployed to Base Sepolia only. Do not deploy it to a network where it would custody real funds without an independent review.
Open a private security advisory on the repository rather than a public issue. Include the affected file and line, the conditions required to reach the code, and the impact. A proof of concept as a Foundry test is the most useful form.
H-2 — a JIT liquidity provider can manufacture drift and collect the surcharge. A
provider who orders their own swap immediately ahead of a pending one can manufacture
apparent drift for that swap and collect the inflated toxicity surcharge as the dominant
in-range liquidity provider. The reference-deviation cap (see below) gates which Chainlink
readings are adopted; it does not bound the live lastTick the fee formula actually
consumes, and Mispricing.MAX_MISPRICING_TICKS (200,000) is ten times looser than the
deviation cap (20,000).
Filed as a High-severity finding in the 8-checklist audit pass (audits/Assay-2026-08-31/).
Five of the six findings from that pass are fixed, each with a regression test in
test/integration/AuditFixes.t.sol — this is the one that is not. See README.md#history for
the other five and how they were fixed.
test/exploit/JitLiquidityDriftManufacture.t.sol demonstrates it end to end against an
unmodified hook. Holding 99% of in-range liquidity, an attacker pushes the pool's own tick to
−4,200 while the reference stays at 0. Their manipulation swap is quoted at the base rate,
not the floor — it is priced from the pre-swap tick, which still agreed with the reference, so
the drift it manufactures is invisible to its own quote. An identical 0.001 ETH swap by an
unrelated party then pays 10,000 pips (the ceiling) plus a surcharge, against 500 pips and
no surcharge for the same swap on the same pool in the control run. Roughly 99% of both flows
back to the attacker as the dominant liquidity provider.
Two things that test does not establish, stated so the severity is not overread: it does not measure the attacker's net profit, and the manipulation is not free — moving the tick that far costs real price impact, which is only partly recovered on exit. Whether the attack clears its own cost depends on the victim's size relative to the manipulation, and that has not been measured. What is established is that the mechanism works and the extraction is from a third party, unlike the self-dealing case analysed above, which nets to zero. Treat this as a real finding of unquantified profitability, not a proven-profitable exploit.
The hook quotes a fee and takes a surcharge on extreme dislocation. It holds no custody of swap principal, takes no liquidity permissions, and cannot prevent a liquidity provider from withdrawing.
The properties the code is built to hold, each covered by tests under test/:
beforeSwapandafterSwapnever revert for a well-formed swap on any pool state. A revert on the swap path is a denial of service against every liquidity provider in the pool, so every failure degrades to a quoted fee instead.- Every quoted fee lies within the
[minFeePips, maxFeePips]range thatfeeBounds()advertises, so a router reading it before quoting is not misled. - The surcharge nets to exactly zero for the hook:
donatedebits it and the returned delta repays it, leaving the swapper as the sole funder and the hook holding no balance. - Only pools whose currencies match the reference oracle's declared pair may attach.
- A reference that is stale, reverting, or out of range degrades to the maximum fee rather than to a wrong price presented as correct.
- A reference the oracle itself reports as fresh is checked a second time against
PoolTwap, a smoothed average of the pool's own tick sampled once per block. One that disagrees by more than the configured cap is treated exactly like a stale reading -- see "Reference deviation cap" below.
Self-dealing on the surcharge. The surcharge is funded by the swapper and donated
pro-rata to in-range liquidity, so an attacker who is also a liquidity provider recovers
their own share of it. Holding share s of in-range liquidity, they pay S and receive
s * S, a net cost of (1 - s) * S. At s = 1 the surcharge is free — but the attacker is
then the only liquidity provider, so the adverse selection being priced is damage to
themselves and there is no counterparty left to extract from. The mechanism degrades to
economically neutral rather than to exploitable.
Removing the direction sign. Mispricing.signedTicks flips sign on zeroForOne, and
that single flip is the entire per-swap mechanism: without it the hook prices the pool's
drift rather than the order's relationship to it, which is what every volatility-based hook
already does. Mutation testing confirms seven tests fail if it is removed.
Pool binding. Disabling either half of the currency check in _beforeInitialize fails a
test written specifically for that half. Mutation testing found the currency0 half
initially unprotected — every existing case also mismatched on currency1, masking it — and
test_Exploit_MismatchedCurrency0AloneIsRefused now covers it.
A Chainlink reading that passes every check ChainlinkReferenceAdapter performs on its own
(fresh, positive, decimals-validated, in range) can still be wrong: a compromised aggregator,
or a misconfiguration on a chain this hook has no independent view of. _advanceReferenceInPlace
checks a fresh reading a second time against PoolTwap, an exponentially weighted average of
the pool's own tick that is sampled once per block rather than once per swap. A reading
that disagrees with that average by more than maxReferenceDeviationTicks is treated exactly
like a stale one: referenceFresh is forced false, the rejected value is never adopted, and
ReferenceDeviationCapTripped fires so an operator can tell "the feed went dark" apart from
"the feed answered, but this hook does not believe it."
Sampling the block-open tick specifically -- the pool's tick as of the end of the previous
block, captured before the current block's own swaps can touch it -- is what makes the check
resistant to being defeated from inside the same transaction that needs a bad reading to look
consistent with the pool's price. test_Exploit_SameBlockPriceManipulationCannotMoveTheTwapAnchor
proves a same-block swap that moves the pool's spot tick hard leaves the anchor completely
unchanged. The sample is gated on its own block tracker, separate from the oracle refresh, so
an oracle that keeps failing cannot re-open the fold mid-block and walk the anchor with a tick
the current block set — test_Regression_TwapDoesNotFoldSameBlockTickWhileOracleIsStuck.
Residual limitation, stated rather than left implicit: the default cap (20,000 ticks,
which is a price ratio of ~7.4x, not the ~2.7x an earlier version of this note claimed) is reasoned from tick-space bounds -- the gap between plausible real-market volatility
and the order-of-magnitude errors a decimals mistake or compromised feed produces -- not
calibrated against real feed-failure data. It catches gross errors, not a subtly wrong value
that happens to sit inside the tolerance. See .env's own comment on
ASSAY_MAX_REFERENCE_DEVIATION_TICKS for the full reasoning, including the specific numbers
that gap is built from.
Three items from an external checklist-driven review of ChainlinkReferenceAdapter and its
read path. Recorded here as explicit decisions rather than left as unexamined omissions.
No L2 sequencer uptime feed. The hook deploys to Base, an OP-stack L2, where the usual mitigation is reading Chainlink's sequencer uptime feed and enforcing a grace period after a restart. This adapter does not.
An earlier version of this document argued the omission was safe because a down sequencer
stops the aggregator updating, so the staleness check fires and the pool over-charges. That
reasoning only holds once the outage exceeds MAX_AGE_SECONDS. A shorter outage — the
common case — freezes the pool's tick and the feed's updatedAt at the same instant, so on
resumption the two still agree with each other while both disagree with the world, and the
drift reads as zero at exactly the moment it is largest. That is under-charging, not
over-charging, and it was wrong.
The hook now detects the condition directly, without a second external call: wall clock and
block production should advance together, and a halt is the one shape where they do not.
When that is observed the reference is distrusted for a fixed window, ChainHaltDetected
fires, and quotes hold at the ceiling until the feed can post a reading from after the halt.
A quiet pool is explicitly not misread as a halt — an untraded hour still advances ~1,800
Base blocks. This is narrower than reading the uptime feed and does not replace it.
Aggregator min/max circuit breakers are not checked. When a feed's true price moves
outside its configured [minAnswer, maxAnswer], some aggregators report the bound instead of
reverting, and this adapter would accept that as a fresh, correct reading. On the feed this is
deployed against, both bounds are non-binding int192 sentinel defaults — minAnswer = 1
(~$1e-8), maxAnswer near 9.6e44 — orders of magnitude outside any price ETH could reach.
Not exploitable against this feed. If this adapter is ever pointed at a different aggregator,
its min/max bounds should be checked before assuming this analysis still holds.
The oracle try/catch can be forced into its catch branch by gas metering. EIP-150
forwards 63/64 of remaining gas to a sub-call, so a caller who meters precisely can starve
the oracle read and force catch. Two things now bound this. Both external calls on the read
path carry explicit gas stipends, so a callee that burns gas rather than reverting cannot
take the swapper's whole budget and strand the rest of the swap. And a call that failed
outright no longer retires the block's refresh — the next swap retries on its own gas — so
one metered dust swap per block can no longer hold the pool at the ceiling while the feed is
healthy. The residual is that the swap doing the metering pays the ceiling itself, which is
the safe direction and is not a position anyone profits from.
- The halt detector assumes
block.numbertracks this chain's own block production. It compares wall-clock time againstblock.numberdeltas to tell a halted chain from a quiet pool (see How it works). That holds on Base and every OP Stack chain, whereblock.numberis the real L2 count. It does not hold on Arbitrum or any Arbitrum Orbit chain (Robinhood Chain included) — there,block.numberreturns the L1 block count, which advances far slower than L2 activity (~12s vs ~250ms). Deployed there unmodified,blocksElapsedwould be understated relative to real L2 time, making the detector fire on ordinary activity, not just genuine halts — the safe direction (forces the fee ceiling, never drops it), but a real usability regression.script/DeployAssay.s.solresolves thePoolManageraddress for whichever chain it's pointed at, including Arbitrum, with no code change required to attempt the deploy — this repo has only ever deployed to Base Sepolia, but nothing stops a fork from doing otherwise. Not fixed: the correct fix is chain-conditional (ArbSys.arbBlockNumber()on Arbitrum), and this hook is not deployed there. - Splitting one trade into many reduces the drift charge. The fee is quoted from the
drift remaining at each swap, and every swap records the tick it left behind, so piece i
of a split trade is priced against a drift piece i-1 already closed. Measured at 20
pieces against one equivalent swap: the splitter keeps an extra 0.095% of notional. Fixing
it means quoting every swap in a block against one block-open tick, which needs an
int24the packed pool state has no room for (248 of 256 bits used) — a second slot would cost ~2,900 gas on a boundary path already at 4% headroom. Accepted and measured rather than half-fixed;test/exploit/SplitSwap.t.solpins the number so a regression is visible. - The toxicity surcharge can be avoided by ending a swap where liquidity is zero.
_donateCeilingOverflowskips the donation whengetLiquidityat the post-swap tick is zero, becausedonatewould revert — but the swapper chooses that tick viasqrtPriceLimitX96. Landing in a gap between positions, or just outside a band the swap consumed, waives the surcharge. Fully consuming a band is the natural shape of a large arbitrage, so this fires without the attacker trying. The surcharge is capped at 2% of notional, which bounds what is avoided, but it is genuinely avoidable. - The reference is only as fresh as the last swap. It is refreshed once per block, in
beforeSwap, so a pool nobody trades holds whatever tick its last swap cached. The adapter's own staleness bound governs the read, not the age of the cache. The first swap after a quiet period does refresh before quoting, so it is charged correctly — butpoolState().referenceFreshread between swaps can describe an arbitrarily old reading. captureShareBpsis calibrated conservatively but its underlying elasticity is bounded rather than measured. See the Risk page in the app's docs (frontend/src/components/docs/pages.tsx, theRiskcomponent; served at/docs/risk).- The adverse-selection gate does not currently pass. It fails on two of its five criteria: 91 positive examples against a floor of 100, and the weakest walk-forward fold at 0.469 against a floor of 0.60. The AUC of 0.7485 clears its own floor, which is 0.65 and not the 0.75 earlier revisions of this file claimed. The mechanism is correct; the evidence that it improves liquidity-provider outcomes is not yet established. Full writeup at the Risk page above.
script/SeedActivity.s.solswaps without slippage protection. Its_swaphelper passesTickMath.MIN_SQRT_PRICE/MAX_SQRT_PRICEas the price limit, so the operator's own swaps are bounded by pool liquidity rather than by a price they chose. On a public testnet mempool a sandwiching bot could worsen the fill. This is an operator tool that moves testnet funds only, and the amounts come from the environment rather than being computed, so a bad fill costs test tokens and nothing else. Recorded rather than fixed because a price limit picked by the script would need recalibrating every time the pool's depth changes.- No independent adversarial review has been completed. Reentrancy through
donate, and cross-swap fee manipulation via the tick recorded inafterSwap, have been reasoned about and partially mutation-tested but not audited by a third party.