# 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 open-round credit; once a round closes its credit is frozen except the killing blow's, which travels with its BOSS (the router that executed the kill pays it out in proportion to its balance); the killer prize goes to the wallet holding more than half of the blow's BOSS, else to the signer; within one transaction a sell takes back the pending credit of the BOSS it hands in, the net credit created never exceeds the HP removed, and pending credit is scoped to the swap's sender), AbilityBoard and OracleGate (abilities must be contracts written by the swarm, accepted only with an IMD oracle attestation, 21 seats, quorum 15). This commit fixes the findings of jobs be67d693, 853ff4df, 94b6c9a5 and 965a5fce; the README lists the known limits. Focus: can anyone drain or lock the pot, steal another wallet's prize or credit, get credit without net damage, block trades or transfers, 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 | `24d0e325cd158f100583bc0dca0028b710664d04` |
| Job | `6d7f5578-9e40-461b-b6ea-7c15a7399372` |
| Judged | 2026-10-13 10:13 UTC |
| Findings | 2 low · 3 info |

Four agents audited the code as it is at `24d0e32`, 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. Low: BOSS sent to the BOSS token contract keeps live credit and strands its damage-pool share, unlike BOSS sent to the hook

`src/BossHook.sol:717`

```
        bool sink = to == pm || to == address(this);
```

onBossTransfer treats only the PoolManager and the hook as sinks. BOSS transferred to the BossToken contract itself is equally dead: BossToken has no function that moves its own balance, so that BOSS can never leave and never be sold. The hook takes the wallet branch for it, moves the sender's open-round credit to address(bossToken) (damage[r][token] += dmg, bought[r][token] += boss) and leaves it inside eligibleDamage[r]. When the round closes, results[r].eligibleDamage includes that credit, so the matching share of the damage pool (damagePool * dmg / eligibleDamage) is either paid into the token contract by anyone calling claimDamage(r, address(boss)), where it is lost, or never claimed and stuck in the hook. The same transfer to the hook address forfeits the credit (eligibleDamage -= dmg) and redistributes that share to the other players, which the README states as the rule for BOSS that can never move again. The pending-credit path has the same gap: `_arrive` (line 800, `bool sink = to == address(this);`) credits BOSS taken out of the pool straight to the token contract. No attacker profit; value is lost for every player in that round, and the inconsistency is at odds with the documented sink rule. Minimal fix: include `to == bossToken` in the sink test on line 717 and in the `sink` test of `_arrive` on line 800. (Merged from audit_economics; verified with the attached proof, which fails on this tree with `credit must not live on the token contract: 400000000000000000 != 0`.)

**Reproduction**

Fixture as in test/utils/BossFixture.sol (initial HP 1 IMD, single-sided pool, BOSS as currency0). 1) alice buyExactIn 0.4 IMD -> damage[1][alice] = 0.4e18, eligibleDamage[1] = 0.4e18. 2) alice calls boss.transfer(address(boss), boss.balanceOf(alice)). Expected: the token contract is a sink like the hook, so eligibleDamage[1] becomes 0 and damage[1][address(boss)] stays 0. Actual: damage[1][alice] = 0, damage[1][address(boss)] = 0.4e18, eligibleDamage[1] = 0.4e18 (the same transfer to address(hook) gives eligibleDamage[1] = 0). 3) bob buyExactIn 2 IMD kills the boss: claimableDamage(1, address(boss)) = 40% of results[1].damagePool, which no player can ever receive. Run: forge test --match-path test/scratch/Proof_26f5be9fe1cc.t.sol (fails on this tree).

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

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

import {Test} from "forge-std/Test.sol";
import {PoolManager} from "v4-core/src/PoolManager.sol";
import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol";
import {IHooks} from "v4-core/src/interfaces/IHooks.sol";
import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {Currency} from "v4-core/src/types/Currency.sol";
import {ModifyLiquidityParams, SwapParams} from "v4-core/src/types/PoolOperation.sol";
import {TickMath} from "v4-core/src/libraries/TickMath.sol";
import {PoolSwapTest} from "v4-core/src/test/PoolSwapTest.sol";
import {PoolModifyLiquidityTest} from "v4-core/src/test/PoolModifyLiquidityTest.sol";
import {LiquidityAmounts} from "v4-core/test/utils/LiquidityAmounts.sol";

import {HookFlags} from "src/HookFlags.sol";
import {BossToken} from "src/BossToken.sol";
import {BossHook} from "src/BossHook.sol";
import {AbilityBoard} from "src/AbilityBoard.sol";
import {IAbilityBoard} from "src/interfaces/IAbility.sol";

contract IMD {
    string public constant name = "IdentityMD";
    string public constant symbol = "IMD";
    uint8 public constant decimals = 18;
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    function mint(address to, uint256 v) external {
        balanceOf[to] += v;
    }

    function approve(address s, uint256 v) external returns (bool) {
        allowance[msg.sender][s] = v;
        return true;
    }

    function transfer(address to, uint256 v) external returns (bool) {
        balanceOf[msg.sender] -= v;
        balanceOf[to] += v;
        return true;
    }

    function transferFrom(address f, address to, uint256 v) external returns (bool) {
        if (allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= v;
        balanceOf[f] -= v;
        balanceOf[to] += v;
        return true;
    }
}

/// BOSS sent to the BOSS token contract itself can never move again (the token has no way to
/// transfer its own balance), exactly like BOSS sent to the hook. The hook forfeits credit sent to
/// itself (`sink`), but keeps credit sent to the token contract as live eligible damage, so that
/// share of the damage pool is paid to a dead address or never paid at all.
contract TokenSinkTest is Test {
    uint160 constant FLAGS = HookFlags.BEFORE_INITIALIZE | HookFlags.BEFORE_SWAP | HookFlags.AFTER_SWAP
        | HookFlags.BEFORE_SWAP_RETURN_DELTA | HookFlags.AFTER_SWAP_RETURN_DELTA;

    PoolManager manager;
    BossToken boss;
    IMD imd;
    BossHook hook;
    PoolKey key;
    PoolSwapTest router;
    bool bossIs0;
    address alice = makeAddr("alice");
    address bob = makeAddr("bob");

    function setUp() public {
        vm.warp(1_800_000_000);
        manager = new PoolManager(address(this));
        imd = new IMD();
        // make BOSS sort below IMD (currency0) deterministically by salt search
        bytes32 initHash = keccak256(type(BossToken).creationCode);
        for (uint256 i = 0;; i++) {
            address p = HookFlags.computeAddress(address(this), bytes32(i), initHash);
            if (p < address(imd)) {
                boss = new BossToken{salt: bytes32(i)}();
                break;
            }
        }
        bossIs0 = true;
        AbilityBoard board = new AbilityBoard(makeAddr("keeper"));
        bytes memory args = abi.encode(
            IPoolManager(address(manager)),
            address(boss),
            makeAddr("keeper"),
            IAbilityBoard(address(board)),
            vm.addr(1),
            keccak256("q"),
            uint256(1 ether),
            uint24(129_000),
            address(imd)
        );
        bytes memory initCode = abi.encodePacked(type(BossHook).creationCode, args);
        (bytes32 salt,) = HookFlags.mineSalt(address(this), initCode, FLAGS, 300_000);
        address addr;
        assembly {
            addr := create2(0, add(initCode, 32), mload(initCode), salt)
        }
        hook = BossHook(addr);
        key = PoolKey(Currency.wrap(address(boss)), Currency.wrap(address(imd)), 12_500, 60, IHooks(addr));
        router = new PoolSwapTest(manager);
        PoolModifyLiquidityTest lp = new PoolModifyLiquidityTest(manager);
        manager.initialize(key, TickMath.getSqrtPriceAtTick(-129_000));
        boss.approve(address(lp), type(uint256).max);
        int24 lower = -129_000 + 60;
        int24 upper = TickMath.maxUsableTick(60);
        uint128 liq = LiquidityAmounts.getLiquidityForAmount0(
            TickMath.getSqrtPriceAtTick(lower), TickMath.getSqrtPriceAtTick(upper), 900_000_000 ether
        );
        lp.modifyLiquidity(key, ModifyLiquidityParams(lower, upper, int256(uint256(liq)), 0), "");
        for (uint256 i = 0; i < 2; i++) {
            address who = i == 0 ? alice : bob;
            imd.mint(who, 10_000 ether);
            vm.prank(who);
            imd.approve(address(router), type(uint256).max);
        }
    }

    function buy(address who, uint256 quoteIn) internal {
        vm.prank(who, who);
        router.swap(
            key,
            SwapParams(false, -int256(quoteIn), TickMath.MAX_SQRT_PRICE - 1),
            PoolSwapTest.TestSettings(false, false),
            ""
        );
    }

    function test_bossSentToTokenContractForfeitsLikeTheHook() public {
        buy(alice, 0.4 ether);
        uint256 dA = hook.damage(1, alice);
        assertGt(dA, 0, "alice credited");
        uint256 bal = boss.balanceOf(alice);
        vm.prank(alice);
        boss.transfer(address(boss), bal);

        // Expected: the token contract is a dead end like the hook, so the credit is forfeited.
        // Actual: it is moved to the token contract and stays eligible.
        assertEq(hook.damage(1, address(boss)), 0, "credit must not live on the token contract");
        assertEq(hook.eligibleDamage(1), 0, "credit of BOSS that can never move must be forfeited");

        buy(bob, 2 ether); // kills
        assertEq(hook.round(), 2);
        assertEq(hook.claimableDamage(1, address(boss)), 0, "no damage-pool share for a dead address");
    }
}
```

### 2. Low: AbilityBoard.propose accepts an EIP-7702 delegated wallet that BossHook.acceptAbility always refuses, burning a proposal id and a paid oracle round

`src/AbilityBoard.sol:33`

```
        if (ability_.code.length == 0) revert NoCode();
```

The two guards on the same proposal disagree. The board only requires non-empty code, so an EOA delegated with EIP-7702 (23 bytes, 0xef0100 || target) passes propose. The hook applies the stricter test at acceptance (src/BossHook.sol:469-470 refuses exactly that shape with AbilityNotAContract). A proposal of a delegated wallet therefore advances id and proposedAt, can be paid for and reviewed by the 21-seat panel (which may even approve it if the delegation target's source passes the five conditions), and its attestation can never be relayed: the id is consumed and the keeper must propose again. Only the keeper (by mistake or a bad swarm artifact) can trigger it and no player funds are at risk, so this is an access/economics seam, not a theft. Fix: mirror the hook's test in propose (`len == 23 && bytes3(ability_.code) == 0xef0100` -> revert) so the board rejects what the hook will reject before an oracle job is bought. (Merged from audit_permissions and audit_economics, which reported the same defect.)

**Reproduction**

Fresh BossFixture. `vm.etch(wallet, abi.encodePacked(hex"ef0100", address(someContract)))` so wallet.code.length == 23; `vm.prank(keeper); board.propose(wallet, "note")`. Expected: propose reverts. Actual: propose succeeds, board.pending() returns (1, wallet), and hook.acceptAbility(attestation(3) signed by the oracle key, sig) reverts with AbilityNotAContract(). Verified by test_board_accepts_7702 in test/scratch/Judge.t.sol on this tree.

### 3. Info: The relayer chooses the activation moment of an approved ability anywhere inside the attestation's validity window

`src/BossHook.sol:476`

```
        uint64 at = uint64(block.timestamp + ABILITY_DELAY);
```

Trust assumption to document, not a bypass. The activation time is relay time + 12h (or the 24h floor), and the oracle question (docs/ORACLE-QUESTION.md) requests validForSeconds 86400, so an approved attestation can be relayed at any moment of a 24-hour span. The keeper, who selects abilities and holds the signed answer, can time when a 4x-hit or 0.25x-heal ability starts (for instance right after a fresh boss with a large seed). Every player sees the queued ability and its activatesAt 12 hours ahead (AbilityQueued, currentAbility()), and the ability cannot be address-specific, so the advantage is timing only. The README documents the keeper's power to select abilities but not this timing freedom. If the requester wants to remove it, bind the activation to the attestation (`at = a.issuedAt + ABILITY_DELAY`, still floored by MIN_ABILITY_INTERVAL) instead of the relay block.

**Reproduction**

BossFixture; keeper proposes a contract ability (id 1); attestation(3) with issuedAt = T and expiresAt = T + 24h, signed by the oracle key. Relay at T + 23h: acceptAbility succeeds and queuedActivatesAt == T + 35h. Relay at T + 1h instead: queuedActivatesAt == T + 13h. Both verified by test_relayTiming in test/scratch/Judge.t.sol on this tree.

### 4. Info: Hook fee rounds to zero on trades under 40 wei of IMD while damage, heal and the killing blow count in full

`src/BossHook.sol:443`

```
        return isBuy ? specified * FEE_PIPS / PIPS : specified * FEE_PIPS / (PIPS - FEE_PIPS);
```

All four fee formulas (_specifiedSideFee here, _feeAndGross lines 430 and 435) divide by 1_000_000 or 975_000 rounding down, so any quote amount below 40 wei pays no hook fee, but _play still uses gross in full: the trade deals or heals that much and, if it brings hp to zero, it is the killing blow and takes the killer prize. The README already lists the zero-fee dust trade as a known limit; what it does not say is that the kill, the one trade that moves the whole pot, can be landed at dust size without contributing to the pot (the previous buy of the same player can leave hp under 40 wei). The economic impact is bounded to at most 39 wei of fee per trade, and a 40-wei kill would have paid 1 wei, so this is a precision note rather than a drain. If the requester wants every non-zero trade to pay, round the fee up in the four formulas or revert swaps whose fee computes to zero. (From audit_math, downgraded to info because the README documents the rounding and the value at stake is wei.)

**Reproduction**

BossFixture (INITIAL_HP = 1 ether). (1) buyExactIn(alice, 39): pot and treasuryOwed unchanged, hp falls by 39, damage[1][alice] == 39, BOSS out 15_111_115 wei. (2) buyExactIn(alice, hp - 10) leaves hp = 10; buyExactIn(bob, 10) pays fee 0 (pot stays 14_999_999_999_999_999), round becomes 2, results(1).killer == bob, killerPrize == 6_750_000_000_000_000 (45% of the pot) and unclaimedKill(bob) equals it. Verified by test_feeZeroDust in test/scratch/Judge.t.sol.

### 5. Info: Zero-inventory flash kill: a buy that kills and a sell of the same BOSS in one unlock keep the killer prize and blow credit with the signer

`src/BossHook.sol:631`

```
        _credit(r, killer, pd, pb);
```

_kill credits the capped pending damage of the killing buy to tx.origin and reserves the killer prize for tx.origin before any BOSS has left the PoolManager. If the same unlock sells that BOSS straight back (deltas net inside the PoolManager, no token transfer, so onBossTransfer never runs), nothing is forfeited: _unpend finds T_PEND_ROUND already advanced, and the heal lands on the fresh boss at full HP. The signer ends the transaction holding no BOSS with 45% of the old pot as killer prize plus a damage share, having paid only the hook and LP fees twice plus price impact. The README states this as design (a flash round trip keeps the kill with the signer; bots race for the blow; a fresh boss after a high-volume round holds a large seed), so it is reported as a documented economic limit, not a bypass: the kill is profitable at zero inventory whenever 0.45 * pot plus the damage share exceeds roughly 7.3% of the HP left. Any mitigation (require the blow's BOSS to be held at the end of the transaction, or scale the killer prize by the share of the blow actually delivered) changes the agreed economic rule and needs a scope decision. (From audit_flow; reproduced with different numbers.)

**Reproduction**

BossFixture (HP 1 IMD): alice buys 8 x 0.1 IMD (hp = 0.2 IMD, pot = 0.012 IMD). Attacker EOA calls ScriptRouter.run([buy(0.21 IMD), sellOwn(<BOSS the buy yields>)]) in one unlock, settling only IMD. Expected by 'credit follows the BOSS': no BOSS received, no prize. Actual: results(1).killer == attacker, unclaimedKill[attacker] == 6_817_500_000_000_000 (45% of the pot), damage[1][attacker] == 0.2e18 (claimable 1_363_500_000_000_000), router and attacker hold 0 BOSS, cost 15_328_121_567_924_262 wei IMD, round 2 opens at 1.5 IMD HP. At this pot/HP ratio the kill loses money; it pays once the pot exceeds about 1.8x the HP left. Verified by test_flashKill_noBossHeld in test/scratch/Judge.t.sol.

---

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