# Audit report

> Audit the IMD Bell contracts in src/ at this commit. It fixes the eleven findings of the earlier audit of commit 672a97b (job a300cdb8-36eb-408a-9b46-c8d94d108cda; README.md lists each fix) and adds a cap on the residual swap. 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 earlier finding is really fixed and whether any fix opened a new problem — the refusal of a ring inside an already unlocked PoolManager and the residual's error handling (only the pool's own price-limit refusal tolerated), the book reference taken from the pool's own price, the deposit cap weighted by distance and released exactly as counted, the void rule for a late opening bell, the ticket rule for orders that fill whole, the round-trip guard keyed on the transaction's signer, the ringer's zero-price path and its chores after a direct ring, the fee exemption for book-rationed inner orders, and the 120-bucket residual cap with its fee waiver against the 10,600,000 gas floor and the 16,777,216 per-transaction gas cap. Then, as before: 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 | `bc64b98a3638039fd0820a30a9733323248ccdb2` |
| Job | `193411a0-b64c-477e-a785-e57fb254dd91` |
| Judged | 2026-11-02 20:15 UTC |
| Findings | 1 medium · 4 low · 3 info |

Four agents audited the code as it is at `bc64b98`, 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: Deposit cap is filled from the band's edge by one order of ten times the cap, closing the book to everyone for a 0.5 % fee

`src/MarketHoursHook.sol:340`

```
            counted = imdValue * (40_000 - 3 * uint256(int256(bucket > ref ? bucket - ref : ref - bucket))) / 40_000;
```

Fix 3 weights an order's count toward bookImdTotal by its distance from the reference, a tenth at the band's edge, and the cap is still one shared figure per book. One buy at ref - 12,000 of ten times depositCap() therefore sets bookImdTotal[s] exactly to the cap and every later placeOrder for that book reverts BookFull, buy or sell. That order never trades (every crossing bucket and the zero-match reference sit above it), so the griefer's whole cost is the 0.5 % cancel fee before 10:50 (or the 0.5 % unfilled-order fee at claim) and the capital comes back the same day. In launch week (cap 1,000 IMD) that is 50 IMD and 10,000 IMD of capital per book; at CAP_MAX 5,000 IMD. Both the opening and closing books, and the guess tickets they fund, are closed to every other user while it lasts. The earlier finding's fix made this ten times dearer than before but did not make it uneconomic. A sell at ref + 12,000 does the same (valued at the reference, then weighted a tenth). Fixing it needs a product decision on what the cap protects: a higher floor weight, a per-wallet share of a book, or charging the cancel and unfilled fees on the counted value rather than on the escrow.

**Reproduction**

Offline harness (test/Offline.t.sol DevEnv, pool at tick 0, day D in launch week so depositCap() == 1_000e18). At D 01:00 a griefer with 10,000 IMD calls placeOrder(2*D, true, lastCleared - 12_000, 10_000e18): succeeds and bookImdTotal(2*D) == 1_000e18. An honest wallet then calls placeOrder(2*D, true, lastCleared, 1e18) and placeOrder(2*D, false, lastCleared, 1e18): expected both accepted (the book holds 10,000 IMD of orders that cannot trade); actual both revert BookFull (0x9cce15fc). At D 10:49 the griefer calls cancelOrder(id) and holds 9,950 IMD again: total cost 50e18 wei IMD. Verified with test/scratch/Judge.t.sol::test_capGriefEdgeOrder on this commit (logs 'griefer cost (wei IMD) 50000000000000000000').

### 2. Low: Distance weight also discounts the most marketable orders, so a book admits ten times its cap of crossing volume

`src/MarketHoursHook.sol:343`

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

The weight at line 340 is symmetric in |bucket - ref|, so a buy far above the reference (willing to pay up to about 3.3x) and a sell far below it (willing to sell down to about 0.3x), the orders most certain to trade, count a tenth as well. The cap's stated purpose (comment at lines 334-336: a far order that will not trade cannot fill the book) does not apply to them, and the figure depositCap() reports bounds marketable volume only at ten times itself: a launch-week book capped at 1,000 IMD clears about 4,900 IMD of matched volume plus a residual against the pool. Nobody loses funds directly; the launch-week limit on how much a book can move through the bell is ten times looser than the README states. A one-sided weight (full for buys at or above the reference and sells at or below it, distance discount only on the passive side) would restore the intent without changing the figures.

**Reproduction**

Offline harness, pool at tick 0, launch week (cap 1,000e18). One wallet places placeOrder(2*D, true, 12_000, 5_000e18) (counted 500e18) and placeOrder(2*D, false, -12_000, 5_000e18) (5,000 coin valued 5,000 IMD at the reference, counted 500e18); bookImdTotal(2*D) == 1_000e18 and both are accepted. indicativePrice(2*D) returns P* = -180 and matched = 5_000e18 coin. ringOpen at 12:00: bells(2*D).pStar == -180, innerFill == 994438940197761099. Claims pay the buy 4,999.999999999999998942 coin and the sell 4,877.229760469412206021 IMD net. Expected: the second order reverts BookFull because 10,000 IMD of crossing orders exceed a 1,000 IMD cap; actual: admitted and cleared. Verified with test/scratch/Judge.t.sol::test_capMarketableDiscount on this commit.

### 3. Low: Round-trip guard keyed on tx.origin couples every account whose transactions share one signer (ERC-4337 bundlers, relayers, batch solvers)

`src/MarketHoursHook.sol:1059`

```
        address id = tx.origin;
```

Fix 8 moved the one-direction-per-block identity from the router to tx.origin on the reasoning (comment at lines 1054-1057) that no router can spend somebody else's direction because it cannot sign for them. That holds only when every user signs their own transaction. For ERC-4337 smart accounts, EIP-7702 delegations relayed by a sponsor, meta-transaction relayers and batch-settlement solvers, tx.origin is the bundler or relayer EOA shared by every account it serves, so the first account's direction in a block is charged to the bundler and every other account behind it is refused the opposite direction with RoundTrip() for that block. A griefer needs only a 1-wei swap in the same bundle to make every opposite swap in it fail (the users still pay the bundler their prefunded gas). This is the same coupling the earlier finding described for routers, moved from users of one router to users of one bundler, which on mainnet is a handful of shared services. No funds are lost; the guard is documented as a speed bump, so this is a residual of the fix rather than a bypass. Keying on the router-reported msgSender() where available, or on both the signer and the swap's sender so only a same-actor round trip is refused, would keep the speed bump for EOAs without binding unrelated accounts together. Reported by four specialists; merged here.

**Reproduction**

Offline harness: pool initialised with a position [-12000, 0), opening bell rung at D 12:00, next block. Two accounts A and B hold coin and IMD and approved PoolSwapTest. vm.prank(A, bundler): router.swap(key, SwapParams(zeroForOne=true, amountSpecified=-1, MIN_SQRT_PRICE+1)) succeeds and sets lastSwap[bundler] = (block.number << 1) | 1. Same block, vm.prank(B, bundler): router.swap(key, SwapParams(false, -1e18, MAX_SQRT_PRICE-1)). Expected: B's first swap of the block is accepted. Actual: it reverts and the revert data contains MarketHoursHook.RoundTrip.selector (0xfc534384, wrapped by the manager's hook-call error). Control: with vm.prank(A, A) and vm.prank(B, B) both succeed. The attached proof (the permissions specialist's test) fails on this commit with 'refused by the round-trip rule'.

**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 {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol";
import {TickMath} from "v4-core/src/libraries/TickMath.sol";
import {PoolSwapTest} from "v4-core/src/test/PoolSwapTest.sol";
import {MarketHoursHook} from "src/MarketHoursHook.sol";
import {DevEnv} from "../Offline.t.sol";
import {IERC20T} from "../Base.sol";

/// The round-trip guard keyed on tx.origin couples every account whose transactions share one origin: two smart
/// accounts (ERC-4337 or any relayed wallet) whose user operations one bundler EOA sends in one block. The second one's
/// swap in the other direction is refused with RoundTrip although the two accounts are unrelated. This test asserts
/// the correct behaviour (the unrelated account's swap goes through), so it fails on the code as it is.
contract RoundTripBundlerTest is DevEnv {
    address internal bundler = makeAddr("bundler");
    address internal acctA;
    address internal acctB;

    function _users(Mkt memory m) internal {
        acctA = _player(m, "smart account A", 100e18, 100e18);
        acctB = _player(m, "smart account B", 100e18, 100e18);
        vm.startPrank(acctA);
        IERC20T(imd).approve(address(router), type(uint256).max);
        m.coin.approve(address(router), type(uint256).max);
        vm.stopPrank();
        vm.startPrank(acctB);
        IERC20T(imd).approve(address(router), type(uint256).max);
        m.coin.approve(address(router), type(uint256).max);
        vm.stopPrank();
        _fundImd(address(this), 100_000e18);
        lp.modifyLiquidity(m.key, IPoolManager.ModifyLiquidityParams(-12_000, 0, 1e23, 0), "");
        _at(D, OPEN);
        m.hook.ringOpen();
        _nextBlock();
    }

    function test_twoAccountsOneBundlerOneBlock_secondMustNotBeRefused() public {
        Mkt memory m = mkt;
        _users(m);
        // account A sells 1 wei of coin; its transaction's origin is the bundler
        vm.prank(acctA, bundler);
        router.swap(
            m.key, IPoolManager.SwapParams(true, -1, TickMath.MIN_SQRT_PRICE + 1), PoolSwapTest.TestSettings(false, false), ""
        );
        // account B, unrelated, buys 1 IMD worth of coin in the same block through the same bundler
        vm.prank(acctB, bundler);
        (bool ok, bytes memory e) = address(router).call(
            abi.encodeCall(
                PoolSwapTest.swap,
                (
                    m.key,
                    IPoolManager.SwapParams(false, -1e18, TickMath.MAX_SQRT_PRICE - 1),
                    PoolSwapTest.TestSettings(false, false),
                    bytes("")
                )
            )
        );
        if (!ok) assertFalse(_has(e, MarketHoursHook.RoundTrip.selector), "refused by the round-trip rule");
        assertTrue(ok, "an unrelated account's swap through the same bundler must not be blocked");
    }
}
```

### 4. Low: Ring reward is priced at the ETH/IMD pool's live spot, which the ringer can move in the same transaction to collect the 3 IMD cap

`src/BellRinger.sol:267`

```
        uint256 r = (costWei * 3 / 2) * (sp * sp >> 96) >> 96;
```

_reward reads slot0 of the canonical ETH/IMD pool at the moment of the ring and converts the gas cost at that spot. Nothing ties the read to a time-weighted or earlier price, and the hook's PoolUnlocked refusal only stops a ring from inside an unlock: the ringer can run a swap in its own unlock, let the manager lock again, call ring(), then swap back, all in one transaction. Selling IMD for ETH with a price limit parks the price in the empty range above the pool's liquidity at no further cost, since v4 moves the price to the limit through zero liquidity, and the read is only masked at sqrt prices of 2^128 or more, so the attacker stops short of that. Each ring then pays min(cap, 1.5 x gas x basefee x manipulated price) = the 3 IMD cap instead of the gas-fair reward. The loss is bounded by the cap (3 IMD per bell, 6 IMD a day from the bell purse) and is only profitable when the ETH/IMD pool's liquidity near spot is thin enough that the round trip's fee and slippage cost less than cap minus fair reward, which the contracts do not control. Pricing the reward from a price the ringer did not author (for example the sqrt price stored at the end of the previous ring) or lowering the cap toward the typical fair reward would close it.

**Reproduction**

Offline harness: ETH/IMD pool (currency0 native, fee 10_000, spacing 200, no hook) initialised at sqrt 10<<96 (100 IMD per ETH) with one position [40000, 60000) of L = 1e18; bell purse topped up with 10 IMD; setRingRate 1 gwei; clock at D 12:00, fresh block. Honest path: ring{gas: 13_000_000}() pays 372317550000000000 wei IMD (0.372 IMD). Attacker path, same starting state, all in one call: (1) router.swap(ethKey, SwapParams(zeroForOne=false, amountSpecified=-100e18, sqrtPriceLimit=100<<96)) sells IMD and parks the price at 10,000 IMD per ETH; (2) ring{gas: 13_000_000}() is not refused and pays 3000000000000000000 wei IMD, the RING_REWARD_CAP; (3) router.swap{value: gotEth}(ethKey, SwapParams(true, -gotEth, 10<<96)) restores the price. Net: ETH delta 0, IMD delta +2847697523760877436 wei (+2.85 IMD, 7.6x the honest reward) drawn from the purse. Expected: the reward follows the gas spent at a price the caller cannot set inside its own transaction; actual: it follows whatever spot the caller sets before ring(). Verified with test/scratch/Judge.t.sol::test_rewardHonest and ::test_rewardManipulated on this commit.

### 5. Low: A cancelled minimum order is a paid guess ticket for 0.0025 IMD, so a wallet buys one sealed guess per bucket and reveals the right one after the answer is public

`src/MarketHoursHook.sol:541`

```
        if (o.cancelled) return true;
```

ticketPaid returns true for any cancelled order, and the cheapest cancelled order is a MIN_ORDER buy (0.5 IMD) cancelled before 10:50 for a fee of ceil(0.5e18 x 50 / 10,000) = 2.5e15 wei (0.0025 IMD), with the rest of the escrow returned. BellGame.reveal runs only after settle has fixed the scored bucket, and the reveal rule lets a wallet pick which of its commits to reveal (the comment at BellGame.sol:145-146 says a wallet holding several tickets reveals its best, so the selective reveal is design). Together they make the sealed guess meaningless: a wallet places and cancels N minimum orders in a loop, commits one guess per bucket around the reference, and after settle reveals the commit that equals the scored bucket for distance 0 and first place. Three wallets take all three places every day for a few tenths of an IMD each. The game pays points only, so no funds are at risk; the defect is that the ticket price does not bind the guess. Binding one commit per wallet per day, requiring every commit of a wallet to be revealed and scoring the worst, or pricing a ticket well above the cancel fee on the smallest order would close it.

**Reproduction**

Offline harness, day D 01:00, reference 0. Wallet W with 100 IMD loops k = -20..20: id = placeOrder(2*D, true, 0, 0.5e18); cancelOrder(id); game.commit(id, keccak256(abi.encode(uint256(k*60 + 887_280), salt, W, D+1))). W's IMD balance falls by exactly 102500000000000000 wei (41 x 0.0025 IMD). Day D+1: ringOpen at 12:00, settle(D+1) at 12:30 (scored = 0 here). W calls reveal(id_for_bucket(scored), scored + 887_280, salt): accepted (ticketPaid true for a cancelled order), distOf(D+1, W) == 0. At 16:00 tally(D+1, W) credits 3 points. Expected: a sealed guess the wallet cannot choose after the answer is public; actual: the wallet picks among 41 sealed guesses after seeing it, for 0.1025 IMD. Verified with test/scratch/Judge.t.sol::test_gameBulkTickets on this commit.

### 6. Info: The ringer's only settle chore runs at the closing bell, after the reveal window has already shut

`src/BellRinger.sol:186`

```
            _chore(6, game, abi.encodeWithSelector(0x8df82800, d), GAS_SETTLE); // settle(uint256)
```

BellGame.reveal requires the day to be settled and block.timestamp % 1 days < 16:00. The ringer calls settle(d) only as a closing-bell chore, at or after 16:00, when no reveal can happen any more; the opening chores do not settle because the window has not ended at 12:00. So the one automated settle a day gets never opens a reveal window: if no player calls settle(d) by hand between openedAt + 1800 and 16:00, nobody can reveal, the day scores nothing, and the close chore marks it settled so finalRanks reports it final with empty ranks. The chore's 80,000-gas stipend (part of the ringer's GasShort floor and of the reward paid from the purse) buys nothing for reveals. Settle is permissionless, so a player who committed can call it; this is a liveness gap in the automation, not a loss.

**Reproduction**

Offline harness. Day D 01:00: a player places placeOrder(2*D, true, 0, 1e18) and commits a guess for day D+1. Day D+1 12:00: ringOpen(); nobody calls settle. 16:00, next block: BellRinger.ring{gas: 13_000_000}() rings the close and the settle chore succeeds: dayState(D+1) returns settled == true, isVoid == false. The player's reveal(orderId, guess, salt) at 16:00 reverts RevealClosed (before 16:00 it reverted NotSettled), and finalRanks(D+1).isFinal == true with no ranks. Expected: the automated settle lands inside [openedAt + 1800, 16:00); actual: every committed ticket for the day is unrevealable unless a user settled by hand. Verified with test/scratch/Judge.t.sol::test_settleChoreAfterRevealWindow on this commit.

### 7. Info: Bells can be rung before the pool is initialised; fix 7 guards placeOrder and cancelOrder but not _ring

`src/MarketHoursHook.sol:641`

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

_ring checks the rung flag, the manager's lock and the gas floor, but not initialized. placeOrder and cancelOrder were given that check in fix 7; the bells were not. Anyone can therefore ring the opening (12:00-16:00) and closing (16:00-20:00) bells of a day on which the hook exists but the factory has not yet initialised the pool. The ring succeeds with an empty book: pStar 0, lastCleared unchanged at 0, the day's settle window opened, the checkpoint counters reset. If the factory initialises the pool later that day, the opening bell for that day is already consumed, beforeSwap already passes AwaitingBell, and book 2d+1 becomes enterable against the initialisation reference without an opening clearing. No funds are at risk because no order can exist before initialisation; reported for the asymmetry with fix 7. A one-line `if (!initialized) revert NotEnterable();` at the top of _ring mirrors _enterable.

**Reproduction**

Offline harness: deploy a fresh MarketHoursHook by CREATE2 at a flag address (test/Base.sol helpers) and do not initialise its pool (initialized() == false). Warp to D 12:00 and call ringOpen() from any address. Expected: refused, as placeOrder (NotEnterable) and cancelOrder (CancelLocked) are before initialisation. Actual: succeeds; bells(2*D) returns rung == true and pStar == 0; window() returns day == D and openedAt == D*86400 + 43200; a second ringOpen() the same day reverts BellAlreadyRung. Verified with test/scratch/Judge.t.sol::test_ringBeforeInit on this commit.

### 8. Info: OVERHEAD undercounts the ring transaction's gas by about 19,000 on the current Foundry; the project's own test fails, the 1.5x factor still covers it

`src/BellRinger.sol:69`

```
    uint256 public constant OVERHEAD = 60_000;
```

The reward counts gasStart - gasleft() plus a fixed OVERHEAD of 60,000 for the gas the in-call count cannot see (the 21,000 base, calldata, dispatch, price read, payment and event). The repository's own test test_R_rewardRecomputedAndOverheadCovers asserts that this count covers the whole transaction and it fails on forge 1.8.3 (the README says 1.5.1 was used): for the cold opening ring the counted gas is 2,485,852 against a measured transaction cost of 2,505,138, so OVERHEAD would need to be about 79,000. Because the reward multiplies the count by 1.5 the ringer is still paid more than the ring costs and no fund is lost, but the stated guarantee that OVERHEAD alone covers the uncounted gas does not hold under the current toolchain's metering. Raise OVERHEAD to the measured figure (about 80,000) or re-derive it on the toolchain used for deployment.

**Reproduction**

forge test --match-test test_R_rewardRecomputedAndOverheadCovers on this commit with forge 1.8.3 (solc 0.8.26, via-IR, cancun): FAIL 'the reward covers the whole transaction's gas: 2485852 < 2505138'. Expected: counted + 1 >= txCost for both bells. Actual: 19,286 short on the opening bell. The other 184 tests pass.

---

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