# Audit report

> Audit BOSS before its launch: BossHook (Uniswap v4 hook, 2.5% fee in IMD on top of the pool LP fee of 1.25%: 1.5% to the pot, 1% to the treasury; every buy hits the boss, every sell heals it; on a kill 10% of the pot seeds the next boss and the rest is split 50/50 between the killing blow and, by damage, the wallets by the damage each still holds; credit follows the BOSS: BossToken reports every transfer to the hook, a buy is credited to the wallet that receives the BOSS from the pool, BOSS handed back to the pool forfeits in proportion, BOSS sent to another wallet carries its credit, the killing blow travels with its BOSS through routers), AbilityBoard and OracleGate (abilities written by the swarm, accepted only with an IMD oracle attestation, 21 seats, quorum 15). Focus: can anyone drain or lock the pot, steal another wallet's prize, block trades through an ability, replay an attestation, or make the treasury pay more than intended? Every finding with a location, a reproduction and a Foundry proof where possible.

| | |
|---|---|
| Repository | https://github.com/HitTheBossApp/boss.git |
| Commit | `b154ad2c6f106bb16b8cbf7ff1308ff56ee28d8a` |
| Job | `be67d693-e50a-4b1f-b9bd-5a7db2c6040c` |
| Judged | 2026-10-12 20:06 UTC |
| Findings | 2 high · 5 low · 3 info |

Four agents audited the code as it is at `b154ad2`, 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: Flash sells never forfeit pending buy credit: a killer buys unbounded damage credit for fees only and dilutes every other wallet's damage pool

`src/BossHook.sol:495`

```
        } else {
            uint256 heal = BossMath.scale(gross, sellMult);
```

The forfeit rule ("BOSS handed back to the pool forfeits in proportion") is implemented only in onBossTransfer, i.e. only when a BossToken transfer into the PoolManager happens. A sell settled by v4 flash accounting inside the same unlock as a buy moves no BOSS token at all: the buy's +BOSS delta nets against the sell's -BOSS delta on the locker, so onBossTransfer never runs. The sell branch of _play (lines 494-500) only heals and leaves T_PEND_DAMAGE / T_PEND_BOSS untouched, so each buy-then-flash-sell round trip adds its full gross to the transaction's pending damage while the heal restores about 95% of the HP it removed. Without a kill the pending credit is sliced proportionally on arrival and the excess vanishes, but _kill (line 528) credits the WHOLE pending damage to the signer and adds it to eligibleDamage[r], which becomes the denominator of the damage pool. The killer therefore buys arbitrary damage credit at about 7.5% of volume in fees (2.5% hook + 1.25% LP per side), holds no BOSS from the round trips, brings the kill no closer, and dilutes every other wallet's share of the 45% damage pool toward zero. In the reproduction below bob is credited 2.49 IMD of damage against a boss whose total HP was 1 IMD and alice's share of the damage pool drops from 50% to 17%, for 0.15 IMD of extra fees. The same unforfeited pending credit is what lets a bundler steal the killer prize (separate finding). Merged from audit_flow. Fix (preserves the design): in the sell branch of _play, slice this transaction's pending credit by min(bossAmount, T_PEND_BOSS) exactly as _release does for a wallet's bought, before healing; or, as a backstop, never let _kill credit more pending damage than the HP the transaction actually removed.

**Reproduction**

Fresh pool (fixture: BOSS/IMD, launch tick -129000, 900M BOSS single-sided, initial HP 1 IMD). (1) alice buys 0.5 IMD exact-in through PoolSwapTest: hp = 0.5e18, damage[1][alice] = 0.5e18, eligibleDamage[1] = 0.5e18. (2) bob (tx.origin) calls an executor that, in ONE PoolManager unlock, runs 8 x {exact-output buy of 100,000 BOSS; exact-input sell of 100,000 BOSS} and then an exact-input buy of 1 IMD, takes the BOSS owed to bob and settles the IMD. No BossToken transfer happens during the round trips (the executor's own BOSS balance is unchanged). Expected: damage[1][bob] <= 0.5e18 (the HP that was left), results[1].eligibleDamage <= 1e18, claimableDamage(1, alice) >= damagePool / 2. Actual on this commit (forge run of test/scratch/ProofFlashSell.t.sol): results[1].killer == bob, damage[1][bob] = 2487704600164581069, results[1].eligibleDamage = 2987704600164581069, damagePool = 37653593246499072, claimableDamage(1, alice) = 6301426818752880 (16.7% instead of 50%), claimableDamage(1, bob) = 31352176969224002; the executor spent 1152595086967823743 IMD in total, i.e. 0.15 IMD of fees above the 1 IMD kill buy, for 2 IMD of extra damage credit. The attached proof fails with "bob credited more damage than the HP that was left: 2487704600164581069 > 500000000000000000".

### 2. High: Kill credit is transaction-scoped and merges across lockers: a bundler that batches its own flash round trips around a victim's killing blow takes the killer prize

`src/BossHook.sol:528`

```
        uint256 pd = _tload(T_PEND_DAMAGE);
        uint256 pb = _tload(T_PEND_BOSS);
        _tstore(T_PEND_DAMAGE, 0);
        _tstore(T_PEND_BOSS, 0);
        _credit(r, killer, pd, pb);
```

Pending buy credit (T_PEND_*) and the killing credit (T_KILL_*) live in transient storage keyed only by the transaction, and _kill credits everything pending to tx.origin. In a transaction that carries several parties' calls (an ERC-4337 bundle, where tx.origin is the bundler EOA and user operations run back to back; a relayer multicall) an earlier party's unconsumed pending credit merges with the victim's blow: _kill records pd/pb as the sum of both, _arrive hands the victim only the proportional slice for the BOSS it takes, and _trackKill compares held BOSS amounts, so whoever later takes more of "the blow's BOSS" out of the pool wins the prize through _moveKill. Because flash sells never forfeit pending credit (previous finding), the bundler creates that pending BOSS for fees only: buy-and-flash-sell round trips before the victim's op leave a large T_PEND_BOSS while HP barely moves; after the victim's op kills, the bundler's own buy taken to its wallet is counted as the blow's BOSS first (_arrive consumes T_KILL_BOSS before T_PEND_BOSS) and out-holds the victim. The victim's operation executes exactly as signed through a stock router and receives all of its BOSS; no code the victim chose is involved, so this is outside the README's "whoever executes the killing swap" limit and contradicts "bundlers that only sign cannot" and "a flash round trip that moves no BOSS changes nothing". Even with flash forfeits fixed, a bundler with capital can still merge a claims-held pre-buy larger than the victim's blow into the kill, so the credit scoping is a defect of its own. Merged from audit_flow. Fix: forfeit this transaction's pending credit on flash sells (previous finding) AND scope pending/kill credit to the locker: record the sender passed to afterSwap and void (not merge) pending credit when a swap arrives from a different sender, so one party's leftover credit can never combine with another party's blow.

**Reproduction**

Fresh pool (initial HP 1 IMD). alice buys 0.5 IMD in her own transaction (hp = 0.5e18). One transaction with tx.origin = bundler EOA runs three calls in order through a Bundle contract: (1) the bundler's executor, in one unlock, 8 x {exact-output buy 100,000 BOSS; exact-input sell 100,000 BOSS} (no BossToken transfer, hp ends about 0.46e18); (2) the victim's smart account buys 0.6 IMD exact-in through the stock PoolSwapTest, which kills and takes the BOSS to the account; (3) the bundler's executor buys 0.7 IMD exact-in and takes the BOSS to the bundler's wallet. Expected: results[1].killer == victim account, unclaimedKill[victim] == results[1].killerPrize, unclaimedKill[bundlerWallet] == 0. Actual on this commit (forge run of test/scratch/ProofBundler.t.sol): round == 2, the victim holds 229562447452112549716755 BOSS (all of its blow), results[1].killer == bundlerWallet, unclaimedKill[victim] == 0, unclaimedKill[bundlerWallet] == 34953603787976883 (the whole killer prize); on the ReviewBase fixture the same shape also leaves the bundler wallet with 0.646e18 of the round's damage credit against the victim's 0.554e18. The attached proof fails with "the killer prize left the victim".

### 3. Low: claimDamage(r, address(hook)) pays raw IMD into the hook itself, where it is stranded for good

`src/BossHook.sol:793`

```
        damageClaimed[r][who] = true;
        totalReserved -= amount;
        _payout(who, amount);
```

onBossTransfer treats the PoolManager as the only sink and _trackKill refuses to make the hook the killer (to == address(this) returns), but the damage path has no such branch: BOSS transferred to the hook (line 621, open round) or taken from the pool to the hook (_arrive, line 661) is credited to address(this) like any wallet. claimDamage(uint64,address) is permissionless and pays `who`, so once the round closes anyone can call claimDamage(r, address(hook)): _payout burns the hook's ERC-6909 claims and takes the IMD to the hook itself. totalReserved is decremented and the IMD now sits as a raw ERC-20 balance in a contract with no sweep and no code path that moves raw tokens (unlockCallback only burns claims), so it is lost, and the accounting invariant the suite asserts ("hook must hold no raw quote token", BossFixture.assertAccounting) is broken on chain. In the native-ETH variant the same claim reverts instead (no receive), so the reserve stays locked. Only the value of credit someone sent to the hook (a mistaken "send to contract", a router sweeping leftovers) is affected and nobody else's share changes, so low. Merged from audit_math and audit_flow (same mechanism, same fix). Fix: revert claimDamage/claimKill when the beneficiary is address(this), and in onBossTransfer treat `to == address(this)` like the pool (forfeit the open-round credit, skip the closed-round move) and skip crediting in _arrive when `to == address(this)`.

**Reproduction**

Fixture (INITIAL_HP = 1 IMD). (1) buyExactIn(alice, 0.5e18): damage[1][alice] = 0.5e18. (2) alice calls boss.transfer(address(hook), <her whole BOSS balance>): damage[1][hook] = 0.5e18, damage[1][alice] = 0 (DamageMoved alice -> hook). (3) buyExactIn(bob, 2e18) kills boss 1. (4) claimableDamage(1, address(hook)) = 8437500000000000. (5) Anyone calls hook.claimDamage(1, address(hook)). Expected: revert, or the share stays in the pot. Actual (test/scratch/Probes.t.sol test_P3): the call succeeds, quoteToken.balanceOf(address(hook)) = 8437500000000000 raw IMD, totalReserved reduced by the same, and no function can move it.

### 4. Low: Exact-input buys and exact-output sells revert on any partial fill, so a price limit (v4's slippage control) bricks those two swap shapes

`src/BossHook.sol:399`

```
            if (isBuy) {
                gross = specified;
                if (uint256(uint128(-quoteDelta)) != gross - fee) revert PartialFillNotSupported();
```

When the paired currency is the specified side (exact-input buy, exact-output sell) the 2.5% fee is pre-taken in beforeSwap from the full amountSpecified. afterSwap then requires the pool to have consumed exactly `gross - fee` (line 399) or produced exactly `gross` (line 402) and reverts PartialFillNotSupported otherwise. In Uniswap v4 a swap stops at sqrtPriceLimitX96 or when in-range liquidity runs out, so any trader or integrator that sets a protective price limit on these two shapes gets a hard revert instead of a partial fill, and an exact-output sell asking for more IMD than the pool holds reverts instead of paying what is available. The other two shapes (exact-output buy, exact-input sell) compute the fee from the realised delta and fill partially, so the four shapes behave asymmetrically. Nobody loses funds (the whole transaction reverts) and only the trader's own transaction is affected, but a class of otherwise valid trades is blocked and a front-runner who pushes the price past a victim's limit turns the victim's partial fill into a revert. Merged from audit_permissions, audit_economics and audit_math (same lines, same mechanism). Fix options: document that these two shapes must be sent with the full-range price limit (the Universal Router and the Uniswap app do), or charge all four shapes in afterSwap from the realised delta so partial fills settle.

**Reproduction**

Fixture (BOSS is currency0, pool seeded single-sided from launchTick + 60). Read the current tick from getSlot0 and set sqrtPriceLimitX96 = TickMath.getSqrtPriceAtTick(tick + 120). Input A: alice swaps exact-input 100 IMD (SwapParams(zeroForOne = false, amountSpecified = -100e18, limit)) through PoolSwapTest. Expected: a partial fill up to the limit, fee on the consumed part. Actual (test/scratch/Probes.t.sol test_P4): the PoolManager reverts with WrappedError(hook, afterSwap selector, PartialFillNotSupported() = 0x7d617bb3, HookCallFailed()). Input B, same limit, exact-output buy amountSpecified = +40,000,000e18: succeeds and alice receives 2697708342345317070791066 BOSS (a partial fill).

### 5. Low: A kill whose BOSS never leaves the pool in the transaction is credited to tx.origin: a relayer or ERC-4337 bundler keeps the killer prize and the whole damage of the blow

`src/BossHook.sol:656`

```
            address signer = tx.origin;
            // Credit the signer already collected stays collected: it is not paid twice.
            if (to != signer && !damageClaimed[kr][signer]) {
```

_kill credits the killing blow's damage and the killer prize to tx.origin (afterSwap passes signer = tx.origin), and _arrive only moves them to the wallet that receives the blow's BOSS from the PoolManager by an ERC-20 transfer in the same transaction. When that BOSS does not leave the pool in the transaction (kept as ERC-6909 claims, a cheaper settlement used by solvers and repeat traders; used to add liquidity; delivered by the executor in a later transaction) the credit stays with tx.origin. For a direct EOA swap that is the player; for an ERC-4337 bundle, a Gelato/OpenZeppelin relayer, an EIP-7702 gas sponsor or any meta-transaction, tx.origin is a third party with no relation to the player, who then holds results[r].killer, the full prize (45% of the pot) and the full damage credit, claimable by anyone to that address via claimKill/claimDamage. The README's claim "bundlers that only sign cannot" and "A relayer, bundler or solver signing for you changes nothing" holds only when the BOSS leaves the pool in that transaction. Merged from audit_permissions and audit_economics (same mechanism). Fix: at minimum correct the documentation and have the front-end force a take to the account; a stronger fix is to credit the locker (the sender argument of afterSwap) rather than tx.origin when nothing arrives, or to hold unarrived kill credit under a neutral key and assign it only on arrival.

**Reproduction**

ReviewBase fixture (INITIAL_HP = 1 IMD) with ScriptRouter funded with 100 IMD. State: round 1, hp = 1e18. Input: vm.prank(smartAccount, relayer); script.run([buy(1 ether), takeBossAsClaims(max)]): the account's router buys with 1 IMD (gross = hp, so it kills) and keeps the BOSS as ERC-6909 claims. Expected per README: the kill belongs to the player's account, or at least not to the relayer. Actual (test/scratch/Probes.t.sol test_P5): results[1].killer == relayer, unclaimedKill[relayer] == 6750000000000000 (the whole killer prize), damage[1][relayer] == 1e18, damage[1][script] == 0, and nothing in a later transaction can move it (the transient T_KILL_* state is gone).

### 6. Low: Kill-prize holder count ignores blow BOSS handed back to the pool, so a custodian that sells part of it before delivering keeps the killer prize

`src/BossHook.sol:630`

```
        // A round this transaction closed: its credit still travels on later hops (a router that
        // custodies the killing blow's BOSS and then delivers it). A closed round is never
        // forfeited, so BOSS going into the pool skips this.
        if (value == 0 || to == pm) return;
```

_trackKill keeps a transient per-wallet count of how much of the killing blow's BOSS each wallet holds and hands the prize to the largest holder (strict >). The count is increased when the BOSS leaves the pool and moved on wallet-to-wallet hops, but a transfer whose destination is the PoolManager returns at line 630 before _trackKill runs, so the sender's count is never reduced when it sells or deposits that BOSS back. A router that custodies the blow (held[router] = 100%, prize on the router), sells 60% of it back into the pool in the same transaction (held[router] stays 100%) and then delivers the remaining 40% to the player leaves held[player] = 40% < held[router] = 60% (stale), so _moveKill never fires and the prize stays on the router address, where claimKill(router) pays a contract that may have no way to forward it. The route need not be malicious: any aggregator leg that sells part of a just-bought amount back to this pool before delivery (a fee taken in BOSS and sold, a rebalance) triggers it; the README promises the prize to "whoever ends up holding the largest share of the BOSS that left the pool with the killing blow". Kept from audit_permissions. Fix: in onBossTransfer, when `to == pm` and T_KILL_ROUND != 0, reduce held[from] by min(value, held[from]) (or run _trackKill with the pool as a sink that never wins) and re-evaluate the largest holder.

**Reproduction**

ReviewBase fixture, carol as tx.origin, the review router holding no BOSS of its own: bob buys 0.2 IMD (hp = 0.8e18). The router runs in ONE transaction: unlock{ buy 1.6 IMD (kills round 1), take all BOSS to the router }, unlock{ exact-input sell of 60% of that BOSS, settled by a router -> PoolManager transfer }, then boss.transfer(alice, the remaining 40%). Expected: results[1].killer == alice and unclaimedKill[alice] == 12150000000000000 (alice holds every remaining unit of the blow's BOSS, the router none). Actual (test/scratch/Probes.t.sol test_P6): results[1].killer == router, unclaimedKill[router] = 12150000000000000, unclaimedKill[alice] = 0, router BOSS balance = 0, alice holds 244830573391583695283557 BOSS; damage[1][router] = 0.48e18 (the credit of BOSS it already sold) and damage[1][alice] = 0.32e18.

### 7. Low: A sell from a wallet that bought nothing this round heals without forfeiting, so a buy from one wallet plus a sell of credit-free BOSS from another accumulates damage credit with no net damage

`src/BossHook.sol:729`

```
    function _release(uint64 r, address from, uint256 value) internal returns (uint256 dmg, uint256 bossAmount) {
        uint256 owned = bought[r][from];
        if (owned == 0) return (0, 0);
```

The forfeit in _release is keyed on bought[round][from], the BOSS the sending wallet received from the pool in the open round. BOSS bought in an earlier round, or held in any other wallet, carries bought == 0, so handing it to the pool heals the boss (sell branch of _play) and forfeits nothing. A player therefore buys from wallet A (hit, damage credited to A) and sells the same amount of pre-round BOSS from wallet B (heal, no forfeit): the boss is back where it was, the pot grew by the two hook fees, and A keeps the full damage credit toward the 45% damage pool. Each round trip costs about 7.5% of volume in fees and buys 1x volume of damage credit without bringing the kill closer, so a holder of old BOSS can keep a round open with heals while accumulating the largest damage share, then kill when the pot is large enough. The README documents that the forfeit is measured against the current round's buys, so part of this is the agreed rule, but the economic consequence (credit without net damage, bounded by the attacker's stock of credit-free BOSS) is the same as the flash-sell finding and is not covered by any test. Kept from audit_permissions. Fix needs a product decision: heal only in proportion to BOSS that carries no open-round credit held by the seller, or charge the heal against open-round credit by balance rather than by `bought`.

**Reproduction**

Fixture (initial HP 1 IMD): round 1, alice buyExactIn 0.4 IMD: hp = 0.6e18, damage[1][alice] = 0.4e18, eligibleDamage[1] = 0.4e18, alice receives about 153,000 BOSS. bob receives the same amount of BOSS from the fixture supply (never bought this round) and sellExactIn that amount. Expected under "BOSS handed back to the pool forfeits": some credit leaves with the BOSS that undid the hit. Actual (test/scratch/Probes.t.sol test_P8): hp = 980311745746806272 (healed back to 98%), damage[1][alice] still 0.4e18, eligibleDamage[1] unchanged at 0.4e18, no Forfeited event.

### 8. Info: Hook fee truncates to zero for trades under 40 wei of IMD while the trade still hits or heals the boss

`src/BossHook.sol:419`

```
    function _specifiedSideFee(SwapParams calldata params, bool isBuy) internal pure returns (uint256) {
        uint256 specified = _abs(params.amountSpecified);
        return isBuy ? specified * FEE_PIPS / PIPS : specified * FEE_PIPS / (PIPS - FEE_PIPS);
```

fee = specified * 25_000 / 1_000_000 is 0 for every exact-input buy below 40 wei; the exact-output sell formula specified * 25_000 / 975_000 is 0 below 39 wei, and the afterSwap formulas (lines 406 and 411) have the same edge. The trade is still fully scored: a 39-wei buy lowers hp by 39 and credits 39 of damage. The invariant "every trade pays 2.5% to pot and treasury" fails at the boundary; the amounts are far below gas cost, so there is no economic exploit. Kept from audit_math for completeness. Fix if wanted: round the fee up for the specified-side shapes.

**Reproduction**

Fixture, fresh pool. buyExactIn(alice, 39). Expected: a fee of at least 1 wei on a scored trade. Actual (test/scratch/Probes.t.sol test_P7): pot = 0, treasuryOwed = 0, hp = 999999999999999961, damage[1][alice] = 39.

### 9. Info: The first valid initialize binds the hook: anyone can front-run the launch and pick the start price within the 300-tick window

`src/BossHook.sol:323`

```
        if (tick < expectedTick - LAUNCH_TICK_TOLERANCE || tick > expectedTick + LAUNCH_TICK_TOLERANCE) {
            revert WrongStartingPrice(sqrtPriceX96, tick, expectedTick);
        }
```

beforeInitialize accepts any caller of PoolManager.initialize as long as the key matches the pin and the start tick is within LAUNCH_TICK_TOLERANCE (300 ticks, about 3%) of the launch tick; the `sender` argument is ignored. Once the hook address is known (its CREATE2 address is mined and announced before the launch transaction), a third party can initialize first with a tick up to 300 away; the launcher's own initialize then reverts in the PoolManager with PoolAlreadyInitialized and the planned single-sided seed range [launchTick + 60, max] can end up straddling the price. The README and launch.json already require the hook deployment and the pool initialize to be atomic; this records the concrete state that atomicity protects against and that nothing in the contract enforces it. Not a fund loss. Merged from audit_permissions and audit_economics. Fix (optional): restrict beforeInitialize's sender to a deployer address passed to the constructor, or require the exact launch sqrtPrice instead of a window.

**Reproduction**

State: a fresh BossToken and a hook deployed for it, pool not yet initialized. A stranger EOA calls manager.initialize(key, TickMath.getSqrtPriceAtTick(launchTick + 299)). Expected: only the launch sequence can bind the pool. Actual (test/scratch/Probes.t.sol test_P9): beforeInitialize returns its selector, poolInitialized == true, round == 1, and the launcher's later initialize(key, getSqrtPriceAtTick(launchTick)) reverts.

### 10. Info: Trust assumptions that bound the game: keeper plus oracle panel decide abilities, abilities see tx.origin, keeper and oracle signer are immutable

`src/BossHook.sol:591`

```
        if (!ok) {
            emit AbilityFailed(ability);
            return (BPS, BPS);
        }
        return (BossMath.clampMult(b), BossMath.clampMult(s));
```

Documented as privileged powers, not defects, as the brief asks. (1) The keeper selects the ability (AbilityBoard.propose) and the 15-of-21 IMD panel approves it; the hook only verifies the signature and the id, never the ability's behaviour, and clamps the result to [0.25x, 4x]. An ability that favours one wallet (modifiers reads s.trader) or a covert trigger (amount parity, timestamp) gives that wallet a 16x cheaper kill (hp/4 IMD against 4*hp for everyone else); a time-dependent ability (1x during review, 0.25x buys / 4x heals after activation) makes the boss need 16x the net volume while the 1% treasury keeps accruing. Only condition (e) of the off-chain oracle question guards this. (2) The keeper is the only proposer, so it can stall abilities forever; keeper is immutable, so a lost key leaves claimTreasury pushing IMD to an unreachable address. (3) oracleSigner, questionHash, PANEL_SIZE and QUORUM are immutable: an IMD key rotation or a panel resize freezes abilities at the last active one with no way back to 1x. (4) No owner, no pause: nothing can stop trading or move the pot, which is the intended design. None of these is a permission bypass; they belong in the launch notes. Merged from audit_permissions and audit_economics (overlapping entries).

**Reproduction**

(1) Deploy an ability whose modifiers(s) returns (s.trader == K ? 40_000 : 2_500, 10_000); if the panel approves it and it activates, K needs hp/4 IMD to kill while others need 4*hp IMD. (2) State: keeper key lost; claimTreasury() succeeds and sends IMD to keeper, no other path can redirect it (line 808). (3) State: IMD rotates its attester; every acceptAbility reverts InvalidSigner (OracleGate line 108) and activeAbility stays as is. All of this is by construction of the immutables declared at lines 69-85 and the OracleGate constructor.

---

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