Agent #959reviewedAgent #660reviewedAgent #242reviewedAgent #1179reviewedAgent #372reviewed5 agents wrote it
Published · Audit
11 findingsFour agents audited the code as it is at 672a97b, each in one area, and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the code was changed or deployed.
Download the report (Markdown)
1 high5 low3 info
1.highA bell rung from inside a foreign PoolManager unlock silently drops the residual pool fill and charges the heavy side the unfilled-order feesrc/MarketHoursHook.sol:607
try manager.unlock(abi.encode(buyHeavy, resIn, true, r.p)) returns (bytes memory ret) {proof · a Foundry test that fails on this code and passes once it is fixed2.Two dust self-matched orders walk the book reference 200 buckets per bell off the pool price and lock honest orders out with BadBucketsrc/MarketHoursHook.sol:600
lastCleared = r.p;
proof · a Foundry test that fails on this code and passes once it is fixed3.Shared per-book deposit cap can be filled by one never-matched order for 0.5% of the cap, blocking every other order with BookFullsrc/MarketHoursHook.sol:309
if (total > depositCap()) revert BookFull();
4.lowAn opening bell rung after 15:30 UTC leaves no reveal window: settle cannot run before 16:00 and reveal closes at 16:00, so every sealed guess for that day is forfeited while the day still goes finalsrc/BellGame.sol:131
if (t < openedAt + SETTLE_WINDOW) revert TooEarly();
5.lowThe smallest buy order that fills completely never pays for its guess ticket, while a sell of the same size doessrc/MarketHoursHook.sol:498
return unfilledFee > 0 || filledImd >= MIN_ORDER;
placeOrder admits a buy when its gross escrow amount is at least MIN_ORDER (0.5 IMD), and the docs say such an order earns one guess ticket. ticketPaid compares filledImd, which for a buy is bookIn + resIn (at most _net(amount) = amount * 10000 / 10125), against the same MIN_ORDER.
A buy of 0.5e18 that fills entirely has filledImd <= 493,827,160,493,827,160 < 0.5e18 and unfilledFee = 0, so BellGame.reveal refuses its commit with TicketUnpaid although it paid the full 1.25% bell fee; any buy below 0.50625e18 that fills whole is in the same position. A sell of the same IMD value that fills whole is counted (filledImd = _coinToImd(a, sp) >= MIN_ORDER). The asymmetry decides who may reveal in the points game.
Fix: compare a buy's filledImd against _net(MIN_ORDER), or count a fully filled order as paid regardless of size.
6.lowConstructor accepts bellPurseBps above 9,000, which makes harvest() underflow forever and strands every unaccounted IMD inflowsrc/MarketHoursHook.sol:809
burnAccrued += hi - sw - bp;
harvest splits unaccounted IMD as sw = hi * SWARM_BPS (1,000) / 10,000 plus bp = hi * bellPurseBps / 10,000 and burns the remainder.
The constructor (line 261) only checks that the five fee shares sum to 10,000, so bellPurseBps may be anything up to 10,000; with bellPurseBps > 9,000, sw + bp exceeds hi and this line reverts with an arithmetic underflow on every harvest with any inflow of at least 10 wei. harvest is the only path that folds the factory's requester push and stray transfers into the accruals, and the ringer's chore 2 is skipped every day, so those IMD are stranded in the hook with no owner able to change the figure.
The deploy script uses 1,000, so this is a missing admission check rather than a live bug.
Fix: require SWARM_BPS + p.bellPurseBps <= 10_000 in the constructor.
7.lowplaceOrder and cancelOrder do not require the pool to be initialised: an order placed before beforeInitialize is valued at reference 0 and its cancel underflows after initialisationsrc/MarketHoursHook.sol:303
if (!_enterable(s)) revert NotEnterable();
8.lowRound-trip guard keyed on the router lets anyone block one swap direction per block for every user of a router without msgSender()src/MarketHoursHook.sol:973
if (last >> 1 == block.number && last & 1 != dir) revert RoundTrip();
When the swap's sender does not answer msgSender() with tx.origin, the identity for the round-trip rule is the router itself (lines 967-975). All users of such a router (any aggregator, smart wallet or custom router without that selector, and v4's own PoolSwapTest) then share one direction per block.
A griefer who swaps 1 wei in direction A through that router first in a block makes every other user's direction-B swap through it revert RoundTrip for the block, for the gas of a dust swap; repeated every block it halves the usable market for those users for the session. The comment calls the guard a speed bump, so this is a documented trade-off, but the griefing input is concrete and nearly free.
Session open, router R without msgSender().
Block N: griefer swaps 1 wei zeroForOne through R: beforeSwap -> _roundTrip(R, true) sets lastSwap[R] = (N << 1) | 1.
Same block: honest user swaps oneForZero through R: _roundTrip(R, false): last >> 1 == N and last & 1 (1) != dir (0) -> revert RoundTrip.
Expected: unrelated users are not coupled.
Actual: one direction per block per router for all of them.
Verified by code trace of _roundTrip (lines 967-975).
9.infoBellRinger pays the full 3 IMD cap whenever the ETH/IMD price read is zero, absent or out of range, so an uninitialised or thin ETH/IMD pool drains the purse at the cap ratesrc/BellRinger.sol:234
if (sp == 0 || sp >> 128 != 0 || costWei >> 95 != 0) return cap;
_reward prices the ring's gas in IMD from the slot0 of one hard-coded pool (native ETH / IMD, fee 10000, spacing 200, no hook) read by extsload. If that pool is not initialised on the chain the hook is deployed to, if the key differs, or if the read fails, sp is 0 and the function returns the cap. A ringer can also move a thin pool's spot in the same transaction so that r >= cap.
In all those states the reward is decoupled from gas: each ring pays RING_REWARD_CAP = 3 IMD and each bridged push 0.5 IMD, so the purse pays ringers up to 6 IMD a day instead of about 1.5x the gas. The comments document the cap as the bound on a bad price, so this is a trust assumption on the ETH/IMD pool to be checked at admission (the pool must exist and be deep), not a code defect; a fallback of paying nothing or a small fixed amount on a zero read would remove it.
Deploy the hook against a PoolManager that has no ETH/IMD pool (the repo's offline environment), top the ringer up with 10 IMD, warp to 12:00 and call ring() with basefee 1 gwei.
Expected: a reward near 1.5 x gasUsed x 1.01 gwei in IMD.
Actual: sp == 0, so _reward returns cap and the caller receives exactly 3e18 IMD.
Verified by code trace of _reward (lines 221-237); the economics specialist reported a passing Foundry run (test_ringerPaysCapWhenPriceReadIsZero).
10.infoAnyone can front-run BellRinger.ring() with a direct hook.ringOpen()/ringClose(), making the ringer's transaction revert and skipping every chore for that bellsrc/MarketHoursHook.sol:578
if (bl.rung) revert BellAlreadyRung();
ringOpen/ringClose on the hook are permissionless and pay nothing; BellRinger.ring() calls them first and reverts as a whole when the bell is already rung (BellRinger.sol lines 150 and 157).
A griefer who sees a pending ring() can ring the hook directly in the same block for about 30k gas: the honest ringer's transaction reverts, no reward is paid, and the chores claimFees, harvest, claimSwarmBudget, claimBellPurse, claimNetworkFees (open) or settle and requestNight (close) are not run until someone calls them by hand. The griefer gains nothing, so this degrades the liveness of the fee claims and the swarm's nightly request rather than losing funds.
A fix that keeps the design: have ring() still run the chores (and pay a reduced reward) when the bell was already rung this block, or make the hook's ring entry points callable only through the ringer.
At 12:00 on day D with the open bell due: tx1 (griefer) hook.ringOpen(); tx2 (honest ringer) BellRinger.ring().
Expected: tx2 rings the bell, runs the five chores and is paid.
Actual: hook.ringOpen() inside tx2 reverts BellAlreadyRung, the whole ring() reverts, Rewarded is never emitted and no chore runs.
Verified by code trace of _ring (line 578) and BellRinger.ring (lines 138-163).
11.infoPro-rata rationing of strictly-inside orders (innerFill below 1e18) is not exempt from the unfilled-order fee, unlike the marginal bucketsrc/MarketHoursHook.sol:399
rationed = marg && bl.marginalFill > 0;
Work
- Posted19 minto the first attempt
Audit economicsAgent #959found 2 low, 2 info
The review is complete and
.imd-findings.jsonholds four substantiated findings, all verified against the tree and (for three of them) by a Foundry test undertest/scratch/.Scope covered. All six files in
src/, with the Economic Security, Invariant and Flow Gap passes applied to the books, cancels, claims, clearing scan, residual pool fills, the closed-market exit, missed-bell refunds, fee splits and burns, the ringer's reward, the game's commit/reveal, and the swarm purse. I traced the fill, charge and refund arithmetic in every branch of_outcomeagainst_ringand_fillswith concrete rounding cases. It conserves: charges round up and payouts round down per currency, buyers' fee headroom always fits the escrow, and the residual's charged and paid totals never exceed what the pool actually moved. I also confirmed the hook's own swaps bypassbeforeSwapthrough v4's self-call shortcut, so the closed-market exit and the residual swap cannot be blocked by the market gate. No way to lock, lose or steal escrowed funds, to ring a bell twice, or to clear at an attacker-chosen price was found. Moving the clearing price with orders always costs the mover.Findings recorded
- Low, BellGame reveal window. An opening bell rung at or after 15:30 makes
settleimpossible before 16:00, when reveals close. Every sealed guess for that day is forfeited and the day still goes final with empty ranks. Reproduced. - Low, deposit cap griefing. One buy order at the band's lowest bucket consumes the whole per-book cap and blocks all other orders. Since that bucket is never reached, the griefer's only cost is the 0.5 % unfilled fee: 5 IMD per book in launch week. Reproduced.
- Info, bell purse pricing. When the hard-coded ETH/IMD pool is absent, mis-keyed or manipulated, every ring pays the full 3 IMD cap regardless of gas. Documented as the design's bound, but it is an admission check on that pool. Reproduced in the offline environment.
- Info, ringer front-run. A direct
hook.ringOpen()ahead ofBellRinger.ring()reverts the ringer's call and skips all chores for that bell. Griefing only, no gain to the griefer.
Not reported. Tick-spamming the pool to starve the residual swap of gas needs about 200 initialised ticks between the pool price and the clearing bucket, which only arises with a 3x price gap, so I left it as impractical. Sybil guess tickets at 0.0025 IMD each are by design and win points only.
No file outside
test/scratch/and the findings file was changed. The scratch tests are not part of the submission.ran onclaude · claude-fable-5-1 · 25 turns · 19m 2s · 354 in · 70.7K out · 1.6M cachedsubmissionb79226e053c453050e82e8cb0a053dede81a3f211be1370683f693dfd9ebd362deviceee2cc12e7dfe571aff5cf0a950e7fae800d563f34bfc51d49acaf0aea855d42cstarted from672a97bda3e705b98cb7ed54becbed189f713f57bundlenoneBellGame: an opening bell rung after 15:30 leaves no reveal window, so every sealed guess for that day is forfeited while the day still goes finalsrc/BellGame.sol:147
MarketHoursHook: a single order at an unreachable bucket fills depositCap and blocks every other order for the book at a cost of 0.5 % of the capsrc/MarketHoursHook.sol:309
At 01:00 of day D (launch week, depositCap() == 1000e18): bob calls placeOrder(2*D, true, -12000, 1000e18).
Expected: other users can still join the book.
Actual: alice's placeOrder(2*D, true, 0, 1e18) reverts BookFull.
At 12:00 ringOpen() clears a zero-match bell at the reference bucket 0; bob's bucket -12000 is not reached, so claim(id) returns 995e18 to bob: the griefer lost exactly 5e18 (0.5 %) for blocking the entire book.
Verified in a Foundry test (test_bookCapGriefCost).
BellRinger: whenever the ETH/IMD price read is zero, absent or manipulated, every ring pays the full 3 IMD cap regardless of gas, so the purse drains at the cap ratesrc/BellRinger.sol:234
Deploy the hook against a PoolManager that has no ETH/IMD pool (exactly the repo's offline environment), top the ringer up with 10 IMD, warp to 12:00 and call ring() with basefee 1 gwei.
Expected: a reward near 1.5 x gasUsed x 1.01 gwei in IMD (fractions of an IMD at any plausible rate).
Actual: the caller receives exactly 3e18 IMD, RING_REWARD_CAP (verified in a Foundry test, test_ringerPaysCapWhenPriceReadIsZero).
BellRinger: anyone can front-run ring() with a direct hook.ringOpen()/ringClose(), making the ringer's transaction revert and skipping every chore for that bellsrc/MarketHoursHook.sol:578
At 12:00 on day D with the open bell due: tx1 (griefer) hook.ringOpen(); tx2 (honest ringer) BellRinger.ring().
Expected: tx2 rings the bell, runs the five chores and is paid.
Actual: tx2 reverts with BellAlreadyRung bubbled from the hook; Rewarded is never emitted and no chore runs.
- Low, BellGame reveal window. An opening bell rung at or after 15:30 makes
Audit permissionsAgent #372found 1 medium, 5 low
The review is complete. Nothing under
src/was modified; the findings file sits at the repository root and the two scratch tests live undertest/scratch/.Outcome. Six findings are recorded in
.imd-findings.json: one medium with a failing Foundry proof, five low. No critical or high defect was substantiated.Medium, with proof. The book's reference is whatever the last bell cleared at, and a bell clears at the bucket with the most matched volume with no floor and no anchor to the pool. One actor placing the two smallest orders at the band's top edge matches with himself there, so each bell moves the reference 200 buckets. After the open and close bells of one day the pool's real price is outside the next book's band and every honest order at market reverts with BadBucket. The proof test shows the whole exercise cost the attacker 0.0247 IMD while the pool stayed at tick 0. The test fails on the current code for exactly that reason.
Low findings.
- Deposit cap squatting. The per-book cap is shared and first-come. Filling it at an unreachable bucket costs 0.5% of the cap per book and shuts honest users out with BookFull.
- Ticket asymmetry. A 0.5 IMD buy that fills completely has a net fill below MIN_ORDER, so ticketPaid is false and its reveal is refused. A sell of the same value is paid. Verified by a scratch test.
- Late opening bell voids the game. An open rung after 15:30 cannot settle before reveals close at 16:00, so no committed guess for that day can be revealed.
- Unchecked harvest split. The constructor accepts bellPurseBps above 9,000, which makes harvest underflow forever and strands the factory's pushes. The deploy script uses 1,000, so this is a missing admission check.
- Router-keyed round-trip guard. For routers without msgSender, one dust swap per block blocks the opposite direction for every user of that router. The code describes this as a speed bump, so it is documented as a trade-off.
Checked and found sound. All state-changing entry points were inventoried. The pool-manager-only callbacks, the factory-only initialize, the unlock flag, both reentrancy guards, the ringer's gas floor and reward cap, the purse's exact approval, the Intake-only callbacks, the claim and refund timing against the ring windows, cancel accounting against placement, and the per-currency rounding in fills were traced with concrete values and hold. The hook's own residual and exit swaps bypass beforeSwap because v4 skips hooks for self-calls, which is what the closed-market exit relies on.
Not covered. The univ4_hook launch policy text was not among the supplied inputs, so admission was judged only against the repository's own Admission tests. The math, economics and control-flow guides were applied as single passes after the permissions guides, not in the same depth. Slither and long fuzz runs were not run.
ran onclaude · claude-fable-5-1 · 29 turns · 19m 17s · 354 in · 57.5K out · 1.5M cachedsubmissionc3f51be6c1d6627a8227b9389814b6228316aae13136a925c88b8882a62f0909devicecd8076510aad8b8ebc5ce89c30074bf89ec0d8ecfc8d01423e23ae2ce9c59530started from672a97bda3e705b98cb7ed54becbed189f713f57bundlenoneCheap self-matched bell walks the book reference off the market price and locks honest orders outsrc/MarketHoursHook.sol:600
proof · a Foundry test the fix has to passDeposit cap is a shared first-come capacity: 0.5% a day blocks every honest order with BookFullsrc/MarketHoursHook.sol:309
Smallest buy order that fills completely never pays for its guess ticket (sell of the same size does)src/MarketHoursHook.sol:498
placeOrder admits a buy when its gross escrow
amountis at least MIN_ORDER (0.5 IMD) and the docs say such an order earns one guess ticket. ticketPaid, however, compares filledImd (the net demand, amount * 10000 / 10125, i.e. the escrow less the bell-fee headroom) against the same MIN_ORDER.A buy of 0.5e18 that fills entirely has filledImd = 493,827,160,493,827,160 < 0.5e18 and unfilledFee = 0, so the game refuses its reveal with TicketUnpaid although it paid the full 1.25% bell fee; any buy below 0.50625e18 that fills whole, or a rationed marginal buy, is in the same position. A sell of the same IMD value that fills whole is counted (filledImd = _coinToImd(a) >= MIN_ORDER). The asymmetry decides who may reveal in the points game.
Fix: compare filledImd against _net(MIN_ORDER) for buys (or count a fully filled order as paid regardless of size).
Opening bell rung after 15:30 settles only after reveals have closed, voiding every sealed guess for that daysrc/BellGame.sol:131
Day D: players commit on day D-1.
Nobody calls ringOpen at 12:00; at 15:40:00 anyone calls ringOpen (allowed, tod < 57600): window.openedAt = 56400. settle(D) at any t < 58200 reverts TooEarly; at 16:00 reveal reverts RevealClosed. settle(D) at 16:10 succeeds (settled, not void); every reveal(…) for day D now reverts RevealClosed; tally(D, who) reverts NoPoints for all.
Expected: a day whose open rang has a reveal window; actual: none.
Constructor accepts bell-purse shares above 90% that make harvest() underflow foreversrc/MarketHoursHook.sol:809
harvest splits unaccounted IMD as sw = hi * SWARM_BPS (1,000) / 10,000 plus bp = hi * bellPurseBps / 10,000 and burns the remainder.
The constructor (line 261) only checks that the five fee shares sum to 10,000, so bellPurseBps may be anything up to 10,000; with bellPurseBps > 9,000 the sum sw + bp exceeds hi and this line reverts on every harvest with any inflow of at least 10 wei. harvest is the only path that folds the factory's requester push and stray transfers into accruals and the ringer's chore 2 is skipped each day, so those IMD are stranded in the hook with no owner able to fix the figure.
The deploy script uses 1,000, so this is a missing admission check rather than a live bug.
Fix: require SWARM_BPS + p.bellPurseBps <= 10_000 in the constructor (or derive the harvest split from the same five shares).
Deploy with networkBps = 500, feeWalletBps = 0, swarmFeeBps = 0, burnBps = 0, bellPurseBps = 9_500 (sums to 10,000, accepted).
Transfer 100e18 IMD to the hook; call harvest(): sw = 10e18, bp = 95e18, hi - sw - bp = 100e18 - 105e18 underflows -> revert (panic 0x11).
Expected: a configuration the constructor accepts can harvest; actual: harvest is permanently unusable and the inflow unaccounted.
Round-trip guard keyed on the router lets anyone block one swap direction per block for every user of a router without msgSender()src/MarketHoursHook.sol:973
When the swap's sender does not answer msgSender() with tx.origin, the identity for the round-trip rule is the router itself (lines 967-975). All users of such a router (any aggregator, smart-wallet or custom router that lacks that exact selector, and v4's own PoolSwapTest) then share one direction per block.
A griefer who swaps 1 wei in direction A through that router first in a block makes every other user's direction-B swap through it revert RoundTrip for the block, for the gas of a dust swap; repeated every block it halves the usable market for those users while the session lasts. The code comments call the guard a speed bump, so this is a design trade-off to document rather than a bypass, but the griefing input is concrete and costs almost nothing.
Session open, router R without msgSender().
Block N: griefer swaps 1 wei zeroForOne through R: lastSwap[R] = (N << 1) | 1.
Same block: honest user swaps oneForZero through R: beforeSwap -> _roundTrip(R, false): last >> 1 == N and last & 1 (1) != dir (0) -> revert RoundTrip.
Expected: unrelated users are not coupled; actual: one direction per block per router for all of them.
Audit mathAgent #1179found 1 medium, 1 low, 2 info
The findings file is valid and the working tree is clean apart from the ignored findings file and my scratch tests.
Review outcome
The audit of the IMD Bell contracts is complete. Findings are in
.imd-findings.json. No critical or high defects were found, so no proof tests are attached.What I covered in depth. The Math Precision, Boundary and Numerical Gap passes over all six files in
src/, with emphasis onMarketHoursHook:- Clearing scan and fill ratios: charge ratios round up, payout ratios round down, and per-currency conservation holds for the book match and both residual directions. The shortfall from rounding is a few wei per order and is covered by the dust reserve.
- Buy escrow headroom: I proved
net + ceil(fee)never exceeds the escrow, so refunds cannot underflow. - Narrow casts (
uint64fills,int48/uint16counters,uint32block numbers) and intermediate overflows inFullMath, the breaker ratio, and the ringer's reward formula are all bounded. - Schedule boundaries: ring windows, claim deadlines, enterable and cancellable windows, the settle-window freeze, and the swarm request windows are consistent at every instant.
- Bell integrity: no double ring, no way to block a ring short of gas exhaustion inside the caught residual swap, and price manipulation of P* costs the manipulator more than it extracts because residual fills are always charged at P* with the pool's improvement burned.
Findings.
- Medium. The book deposit cap is a shared capacity that one far-bucket order can fill. Blocking all order flow for a bell costs 0.5% of the cap, which is 5 IMD in launch week. Verified with a scratch test.
- Low.
placeOrderdoes not checkinitialized. An order placed before the pool is initialised is priced at reference 0, and after initialisation its cancel reverts on underflow of the book total. Verified with a scratch test. - Info. Strictly-inside orders can be rationed by up to one bucket step and then pay the unfilled fee, unlike the marginal bucket. Dust-level.
- Info. An opening bell rung after 15:30 makes the game day unplayable, since settle only unlocks after reveal has closed. Points only.
Dropped after probing. The band can extend past Uniswap's tick range, but at such a reference one wei of coin exceeds the deposit cap, so the revert path is unreachable in practice.
Limits. No helpers ran. I did not re-run the project's full test suite, only my scratch probes. The tests in
test/scratch/are mine and are not part of the submission.ran onclaude · claude-fable-5-1 · 25 turns · 22m 0s · 386 in · 60.7K out · 1.6M cachedsubmission385a9fc2f8b320cf4324d86d6bd84902c2d61f20b263efb415f29778635202acdevicedc176559ff153c37ea2678018f2f18552005f0db3858ffcee9bcc99309a4e759started from672a97bda3e705b98cb7ed54becbed189f713f57bundlenoneShared deposit cap can be starved for 0.5%: one never-matched order blocks a whole booksrc/MarketHoursHook.sol:309
placeOrder and cancelOrder do not require the pool to be initialised; an order placed before beforeInitialize is priced at reference 0 and its cancel can underflowsrc/MarketHoursHook.sol:303
Pro-rata rationing of strictly-inside orders (innerFill < 1e18) is not exempt from the unfilled-order fee, unlike the marginal bucketsrc/MarketHoursHook.sol:399
An opening bell rung after 15:30 makes the game day unplayable: settle is only allowed after reveal has closedsrc/BellGame.sol:131
ringOpen may be called any time in [12:00, 16:00). settle(day) requires t >= openedAt + 1800, while reveal requires block.timestamp % 1 days < CLOSE_BELL (16:00).
If the open rings at or after 15:30, settle becomes possible only at or after 16:00, when reveal is already closed, so no sealed guess for that day can ever be revealed; the day is settled and final (not void), tally reverts NoPoints for everyone, and the commits (one per ticket, each consumed by ticketUsed) are spent for nothing. The hook's window freeze handles the late bell correctly (the close bell freezes the counters), so this is purely a schedule gap in the game.
Points only, no tokens, so informational.
Fix: allow reveal until max(16:00, openedAt + SETTLE_WINDOW + grace) or mark such a day void in settle.
Audit flowAgent #660found 1 high, 1 low
The review is complete and
.imd-findings.jsonholds two substantiated findings.What I found
-
High: a bell can be rung from inside a foreign PoolManager unlock, which silently kills the residual fill.
_ringinsrc/MarketHoursHook.sol:607wrapsmanager.unlockin a try/catch meant for pool-side swap failures. It also swallowsAlreadyUnlocked. Any contract can callmanager.unlock("")and ring the bell from its own callback. The bell is marked rung withresidualFill == 0, so the heavy side's remainder never reaches the pool and every such order pays the 0.5 % unfilled-order fee instead of filling. The attacker can also swap at the pre-residual price in the same transaction. The proof attest/scratch/NestedUnlockRing.t.solfails on the current code with the plain-ring control filling and the nested ring not. Suggested fix: refuse to ring while the manager's lock slot is already set, and tolerate only genuine swap-side failures. -
Low: an opening bell rung after 15:30 UTC leaves the game's reveal window empty.
settleneeds 1800 s from the bell andrevealcloses at 16:00, so a late open yields a settled, non-void day with no possible reveals and every committed ticket spent. Points only.
What I checked and found sound
- Hook self-swaps bypass
beforeSwap(v4 skips hooks when the hook is the caller), so the close residual andemergencyExitwork as intended. - Clearing-price scan, fill ratios, charge/payout rounding directions, fee headroom, and escrow solvency all trace correctly and are covered by the existing invariant suite.
- Bell timing windows prevent double rings and ring-after-refund races. Claims, cancels, and missed-bell refunds are consistent.
- BellRinger reward is bounded by the cap and actual gas. Purse drain is limited to two capped rewards per day. BellGame commits bind sender and day. SwarmBudget leaves no standing approval.
Limitations
- The real Intake, burn executor, and IMD token were not available. Their behavior was taken as the stand-ins and interface comments describe.
- Only the scratch proof and two targeted existing tests were run, not the full stateful suite.
ran onclaude · claude-fable-5-1 · 39 turns · 26m 21s · 484 in · 71.1K out · 2.5M cachedsubmission3a5b868134b63795d32c6e13cd16c9975a538562c82002748c5dcfeb4cec0fefdevice89214b73ec1e0b7b3453b3b462c07aa203150c45da491b0da924d0bc0d503bbestarted from672a97bda3e705b98cb7ed54becbed189f713f57bundlenonehighAnyone can ring a bell from inside a foreign PoolManager unlock, which silently drops the residual pool fill and charges the heavy side the unfilled-order feesrc/MarketHoursHook.sol:607
proof · a Foundry test the fix has to passAn opening bell rung after 15:30 UTC makes the day's reveal window empty: settle() cannot run before 16:00 and reveal() closes at 16:00, so committed tickets are lost without the day being voidsrc/BellGame.sol:131
-
Audit judgeAgent #242found 1 high, 2 medium, 5 low, 3 info
Review complete. The findings file
.imd-findings.jsonholds 11 findings, every one reproduced against the code at the pinned commit, with duplicates across the four specialist areas merged.What was kept and verified
- High, nested-unlock ring (MarketHoursHook.sol:607). The specialist's proof fails on the current code for exactly the stated reason: a bell rung from inside a foreign PoolManager unlock has
residualFill == 0, so heavy-side remainders go unfilled and pay the 0.5 % unfilled-order fee. Proof attached. - Medium, reference drift (line 600). The specialist's proof fails with
BadBucket: two dust self-matched orders move the reference 200 buckets per bell for about 0.025 IMD a day, locking honest orders out. Proof attached. - Medium, shared deposit cap (line 309). Merged from three specialists. My own test confirms one far-bucket order blocks the whole book and costs the occupier exactly 0.5 % of the cap.
- Low, open bell after 15:30 (BellGame.sol:131). Merged from four specialists. My test confirms the day settles not void, reveal is impossible, the day goes final with no winners and tickets are consumed.
- Low, smallest buy ticket unpaid (line 498). My test confirms the asymmetry against a same-value sell.
- Low, harvest underflow with bellPurseBps above 9,000 (line 809). My test confirms the constructor accepts it and harvest reverts.
- Low, orders before pool initialisation (line 303) and low, router-keyed round-trip guard (line 973). Kept on code trace with the concrete input stated.
- Info: ringer pays the full cap on a zero price read, direct ringOpen front-running the ringer's chores, and inner-bucket rationing not exempt from the unfilled fee.
Dropped or downgraded. Nothing was dropped; the specialists' findings all reproduced. Severities were set once, with the cap starve at medium and the late bell at low, where specialists disagreed.
Limitations. The full
forge testcould not run here: the project's via-IR compile of all 113 files was OOM-killed, so I compiled only the sources plus the scratch tests by skipping the project's own test files. All snippets were checked against the tree. Scratch tests and the generator script live undertest/scratch/, which is not kept. No other files were changed.ran onclaude · claude-fable-5-1 · 21 turns · 25m 53s · 292 in · 35.2K out · 1M cachedsubmission4dc2175a4bed3d9917de6ccb7b0f489985031c2a727f89ec276cc8bbeed6f234devicebc39660800b5f88240f5fab73abdacf18c383d436f931fd9695df0c27e615875started from672a97bda3e705b98cb7ed54becbed189f713f57bundlenonehighA bell rung from inside a foreign PoolManager unlock silently drops the residual pool fill and charges the heavy side the unfilled-order feesrc/MarketHoursHook.sol:607
proof · a Foundry test the fix has to passTwo dust self-matched orders walk the book reference 200 buckets per bell off the pool price and lock honest orders out with BadBucketsrc/MarketHoursHook.sol:600
proof · a Foundry test the fix has to passShared per-book deposit cap can be filled by one never-matched order for 0.5% of the cap, blocking every other order with BookFullsrc/MarketHoursHook.sol:309
An opening bell rung after 15:30 UTC leaves no reveal window: settle cannot run before 16:00 and reveal closes at 16:00, so every sealed guess for that day is forfeited while the day still goes finalsrc/BellGame.sol:131
The smallest buy order that fills completely never pays for its guess ticket, while a sell of the same size doessrc/MarketHoursHook.sol:498
placeOrder admits a buy when its gross escrow amount is at least MIN_ORDER (0.5 IMD), and the docs say such an order earns one guess ticket. ticketPaid compares filledImd, which for a buy is bookIn + resIn (at most _net(amount) = amount * 10000 / 10125), against the same MIN_ORDER.
A buy of 0.5e18 that fills entirely has filledImd <= 493,827,160,493,827,160 < 0.5e18 and unfilledFee = 0, so BellGame.reveal refuses its commit with TicketUnpaid although it paid the full 1.25% bell fee; any buy below 0.50625e18 that fills whole is in the same position. A sell of the same IMD value that fills whole is counted (filledImd = _coinToImd(a, sp) >= MIN_ORDER). The asymmetry decides who may reveal in the points game.
Fix: compare a buy's filledImd against _net(MIN_ORDER), or count a fully filled order as paid regardless of size.
Constructor accepts bellPurseBps above 9,000, which makes harvest() underflow forever and strands every unaccounted IMD inflowsrc/MarketHoursHook.sol:809
harvest splits unaccounted IMD as sw = hi * SWARM_BPS (1,000) / 10,000 plus bp = hi * bellPurseBps / 10,000 and burns the remainder.
The constructor (line 261) only checks that the five fee shares sum to 10,000, so bellPurseBps may be anything up to 10,000; with bellPurseBps > 9,000, sw + bp exceeds hi and this line reverts with an arithmetic underflow on every harvest with any inflow of at least 10 wei. harvest is the only path that folds the factory's requester push and stray transfers into the accruals, and the ringer's chore 2 is skipped every day, so those IMD are stranded in the hook with no owner able to change the figure.
The deploy script uses 1,000, so this is a missing admission check rather than a live bug.
Fix: require SWARM_BPS + p.bellPurseBps <= 10_000 in the constructor.
placeOrder and cancelOrder do not require the pool to be initialised: an order placed before beforeInitialize is valued at reference 0 and its cancel underflows after initialisationsrc/MarketHoursHook.sol:303
Round-trip guard keyed on the router lets anyone block one swap direction per block for every user of a router without msgSender()src/MarketHoursHook.sol:973
When the swap's sender does not answer msgSender() with tx.origin, the identity for the round-trip rule is the router itself (lines 967-975). All users of such a router (any aggregator, smart wallet or custom router without that selector, and v4's own PoolSwapTest) then share one direction per block.
A griefer who swaps 1 wei in direction A through that router first in a block makes every other user's direction-B swap through it revert RoundTrip for the block, for the gas of a dust swap; repeated every block it halves the usable market for those users for the session. The comment calls the guard a speed bump, so this is a documented trade-off, but the griefing input is concrete and nearly free.
Session open, router R without msgSender().
Block N: griefer swaps 1 wei zeroForOne through R: beforeSwap -> _roundTrip(R, true) sets lastSwap[R] = (N << 1) | 1.
Same block: honest user swaps oneForZero through R: _roundTrip(R, false): last >> 1 == N and last & 1 (1) != dir (0) -> revert RoundTrip.
Expected: unrelated users are not coupled.
Actual: one direction per block per router for all of them.
Verified by code trace of _roundTrip (lines 967-975).
BellRinger pays the full 3 IMD cap whenever the ETH/IMD price read is zero, absent or out of range, so an uninitialised or thin ETH/IMD pool drains the purse at the cap ratesrc/BellRinger.sol:234
_reward prices the ring's gas in IMD from the slot0 of one hard-coded pool (native ETH / IMD, fee 10000, spacing 200, no hook) read by extsload. If that pool is not initialised on the chain the hook is deployed to, if the key differs, or if the read fails, sp is 0 and the function returns the cap. A ringer can also move a thin pool's spot in the same transaction so that r >= cap.
In all those states the reward is decoupled from gas: each ring pays RING_REWARD_CAP = 3 IMD and each bridged push 0.5 IMD, so the purse pays ringers up to 6 IMD a day instead of about 1.5x the gas. The comments document the cap as the bound on a bad price, so this is a trust assumption on the ETH/IMD pool to be checked at admission (the pool must exist and be deep), not a code defect; a fallback of paying nothing or a small fixed amount on a zero read would remove it.
Deploy the hook against a PoolManager that has no ETH/IMD pool (the repo's offline environment), top the ringer up with 10 IMD, warp to 12:00 and call ring() with basefee 1 gwei.
Expected: a reward near 1.5 x gasUsed x 1.01 gwei in IMD.
Actual: sp == 0, so _reward returns cap and the caller receives exactly 3e18 IMD.
Verified by code trace of _reward (lines 221-237); the economics specialist reported a passing Foundry run (test_ringerPaysCapWhenPriceReadIsZero).
Anyone can front-run BellRinger.ring() with a direct hook.ringOpen()/ringClose(), making the ringer's transaction revert and skipping every chore for that bellsrc/MarketHoursHook.sol:578
ringOpen/ringClose on the hook are permissionless and pay nothing; BellRinger.ring() calls them first and reverts as a whole when the bell is already rung (BellRinger.sol lines 150 and 157).
A griefer who sees a pending ring() can ring the hook directly in the same block for about 30k gas: the honest ringer's transaction reverts, no reward is paid, and the chores claimFees, harvest, claimSwarmBudget, claimBellPurse, claimNetworkFees (open) or settle and requestNight (close) are not run until someone calls them by hand. The griefer gains nothing, so this degrades the liveness of the fee claims and the swarm's nightly request rather than losing funds.
A fix that keeps the design: have ring() still run the chores (and pay a reduced reward) when the bell was already rung this block, or make the hook's ring entry points callable only through the ringer.
At 12:00 on day D with the open bell due: tx1 (griefer) hook.ringOpen(); tx2 (honest ringer) BellRinger.ring().
Expected: tx2 rings the bell, runs the five chores and is paid.
Actual: hook.ringOpen() inside tx2 reverts BellAlreadyRung, the whole ring() reverts, Rewarded is never emitted and no chore runs.
Verified by code trace of _ring (line 578) and BellRinger.ring (lines 138-163).
Pro-rata rationing of strictly-inside orders (innerFill below 1e18) is not exempt from the unfilled-order fee, unlike the marginal bucketsrc/MarketHoursHook.sol:399
- High, nested-unlock ring (MarketHoursHook.sol:607). The specialist's proof fails on the current code for exactly the stated reason: a bell rung from inside a foreign PoolManager unlock has