# 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 that did not sell), 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 | `9ac2dceedf12d68503abc14ac8557162a87a9f26` |
| Job | `2f96ed9f-2185-4cfd-88b9-b1df9fde54e9` |
| Judged | 2026-10-12 12:03 UTC |
| Findings | 2 medium · 2 low · 3 info |

Four agents audited the code as it is at `9ac2dce`, 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: Any contract a player calls can forfeit the player's whole damage share with a zero-value sell (sells keyed on tx.origin, forfeit applied before the amount is checked)

`src/BossHook.sol:457`

```
                sold[r][trader] = true;
```

afterSwap attributes every sell to tx.origin (line 342) and the sell branch of _play (lines 456-462) sets sold[round][tx.origin] and removes that wallet's entire damage from eligibleDamage before it looks at gross. The buy branch returns early when dmg == 0; the sell branch has no such guard. An exact-input sell of 1 wei of BOSS moves 0 IMD (the pool's 1.25% LP fee consumes the wei: delta (-1 BOSS, 0 IMD), hook fee 0, heal 0, no claim minted), and at the launch tick even a sell with no BOSS at all gives delta (0, 0); both still forfeit. Because the key is tx.origin and not the account whose BOSS moved, the sell need not come from the victim: any contract the victim calls for any reason (a fake airdrop claim, a quest or leaderboard helper, a compromised dapp) can run the swap through any router inside the victim's transaction. The victim's damage leaves eligibleDamage, so the remaining eligible wallets, the attacker among them, split the 45% damage pool without the victim. This is the 'steal another wallet's prize' case the brief asks about: the victim signed no sale of their own BOSS. The same code path is a footgun for honest users: an aggregator route or dust sweep that sells 1 wei of BOSS silently forfeits the wallet's round. The README's known limits cover bundler misattribution but not a third party forfeiting a wallet at zero cost. Fix within the current rules: do not enter the forfeit when the trade moved no value (return when gross == 0 before touching sold), which removes the free trigger. Closing the phishing vector itself is a design decision for the requester: attribute sells to the account that actually traded (the router's msgSender(), else the calling contract, never tx.origin), or bound the forfeit by what was actually sold. Merged from audit_math, audit_flow, audit_economics and audit_permissions; all four reproduce.

**Reproduction**

Fixture: PoolManager, BOSS/IMD pool at |tick| 129000, LP fee 12500, spacing 60, initialHp 1 IMD, single-sided BOSS liquidity (as the launch). (1) alice exact-input buy 0.5 IMD with hookData = abi.encode(alice); bob buys 0.25 IMD: damage(1, alice) = 0.5e18, damage(1, bob) = 0.25e18, eligibleDamage(1) = 0.75e18. (2) bob deploys a contract holding 1 wei of BOSS whose claimAirdrop() calls swapRouter.swap(key, SwapParams(zeroForOne = bossIsCurrency0, amountSpecified = -1, extreme limit), settings, ''). (3) alice calls claimAirdrop() from her EOA (vm.prank(alice, alice)). Observed: alice's IMD and BOSS balances unchanged, hp unchanged, pot unchanged (delta -1 BOSS / 0 IMD, fee 0). Expected: sold(1, alice) == false and eligibleDamage(1) == 0.75e18. Actual: sold(1, alice) == true, Forfeited(1, alice, 0.5e18) emitted, eligibleDamage(1) == 0.25e18. (4) bob buys 1 IMD and kills: expected claimableDamage(1, alice) == damagePool * 2 / 3 and claimableDamage(1, bob) == damagePool / 3; actual claimableDamage(1, alice) == 0, claimDamage(1, alice) reverts SoldThisRound, claimableDamage(1, bob) == the whole damagePool. Also reproduced: alice selling 1 wei of her own BOSS (sellExactIn(alice, 1)) leaves her IMD balance, hp and the hook's claims unchanged yet sets sold(1, alice) and eligibleDamage(1) = 0; and an EOA with zero BOSS selling 1 wei at the launch tick gets delta (0, 0) and is still marked sold. Run: forge test --match-path test/scratch/BaitForfeit.t.sol (fails on this code at 'alice is marked as a seller although only the bait's 1 wei moved').

### 2. Medium: Sell-forfeit rule is void for a single signer: buys are credited to a free hookData label while sells only mark tx.origin

`src/BossHook.sol:342`

```
        address trader = isBuy ? _resolveTrader(sender, hookData) : tx.origin;
```

The damage half of the pot (45% on a kill) is meant for wallets that did not sell during the fight. afterSwap keys the two halves of that rule on different identities: a buy is credited to whatever address the trader names in hookData (_resolveTrader, lines 615-624: any 32- or 20-byte address, no relation to msg.sender or tx.origin required), while a sell sets sold[round][tx.origin]. One EOA therefore buys with hookData = abi.encode(label) for a second address it controls, sells the entire position from the signing EOA in the same round, and the label is never marked sold: it keeps its full pro-rata share of the damage pool while honest holders are diluted. No second signer, token transfer or extra transaction is needed, so the bypass costs nothing and is invisible on-chain. The NatSpec at lines 612-614 reasons that naming someone can only gift them damage; it does not consider that the gift goes to the seller's own alter ego, which makes the central economic rule unenforceable against anyone who reads the code. The README accepts that 'selling from a different wallet than the one that bought evades the forfeit'; that limit presumes a second wallet and a BOSS transfer, whereas here the asymmetry in the code itself removes even that cost, and the same effect hits smart-account users unintentionally (buys credited to the account, sells to the bundler EOA). Fix: credit buys and sells to the same identity. Resolve the trader once per swap (router msgSender(), then the sender/router) and use it for damage, sold and the kill credit, and either drop hookData naming or apply the sold flag to the named label too; this is a design decision for the requester because the README documents the current attribution. Merged from audit_economics and audit_permissions; both reproduce.

**Reproduction**

Fixture as above (INITIAL_HP 1 IMD). (1) alice exact-input buy of 0.5 IMD with hookData = abi.encode(aliceLabel), aliceLabel being any address she controls: damage(1, aliceLabel) = 0.5e18, damage(1, alice) = 0. (2) bob buys 0.25 IMD with hookData = abi.encode(bob): eligibleDamage(1) = 0.75e18. (3) alice sells her whole BOSS balance exact-input from her EOA (tx.origin = alice): the boss heals, boss.balanceOf(alice) == 0, sold(1, alice) == true. Expected under 'wallets that did not sell': eligibleDamage(1) == 0.25e18 and claimableDamage(1, aliceLabel) == 0. Actual: eligibleDamage(1) stays 0.75e18 (sold(1, aliceLabel) == false). (4) bob buys 2 IMD and kills: expected claimableDamage(1, bob) == results(1).damagePool; actual claimableDamage(1, aliceLabel) == damagePool * 2 / 3 and claimableDamage(1, bob) == damagePool / 3, and claimDamage(1, aliceLabel) pays the seller's label. Run: forge test --match-path test/scratch/SellForfeitBypass.t.sol (fails on this code at 'the BOSS bought under aliceLabel was sold, yet it stays eligible: 750000000000000000 != 250000000000000000').

### 3. Low: Single ability queue slot locks out a fresh oracle approval for up to 24 hours, long enough for the attestation to expire

`src/BossHook.sol:404`

```
        if (queuedAbility != address(0)) revert AbilityAlreadyQueued();
```

acceptAbility refuses any new attestation while an ability is queued, and a queued ability can wait up to 24 hours because its activation is pushed to lastActivationAt + MIN_ABILITY_INTERVAL (lines 409-413). _syncAbility only frees the slot once block.timestamp >= queuedActivatesAt. IMD attestations are valid for 86400 seconds (validForSeconds in docs/ORACLE-QUESTION.md) and OracleGate rejects them once expiresAt <= block.timestamp. When the swarm proposes the next ability shortly after the previous one was relayed, the panel's approval arrives while the previous ability is still queued, and the window in which it can be relayed is [queuedActivatesAt, expiresAt), which shrinks to minutes or to nothing. The approval is then wasted: it can never be queued, the keeper must re-propose (new id) and pay the panel again, and the daily ability cadence slips. No funds are at risk; this is a liveness and cost defect in the ability pipeline. Fix: allow one approval to be recorded while another is queued (a two-deep queue, or let acceptAbility record the newer id with its activation computed from the existing rules), or document that the keeper must not propose until the queued ability has activated. Kept from audit_flow; its proof reproduces.

**Reproduction**

Timeline with T = first accept, hook deployed and pool initialized, attestations with expiresAt = issuedAt + 86400. (1) Ability #1 proposed, approved, relayed at T: queuedActivatesAt = T+12h, lastActivationAt = T+12h. (2) Ability #2 proposed at T+11.5h, approved and relayed at T+12h: _syncAbility activates #1, #2 is queued with queuedActivatesAt = T+36h (24h after the previous activation). (3) Ability #3 proposed at T+12h, approved at T+12.5h with expiresAt = T+36.5h. acceptAbility(a3, sig3) at T+12.5h: expected to be accepted (scheduled no sooner than T+60h); actual revert AbilityAlreadyQueued. Every relay attempt until T+36h reverts the same way. At T+37h acceptAbility(a3, sig3) reverts InvalidAttestation (expired). Ability #3 can never be queued although the panel approved it for the current pending id. Run: forge test --match-path test/scratch/Proof_5628727c3435.t.sol (fails on this code with 'approved ability #3 could never be queued: locked out by queued #2 until its attestation expired').

**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 {TickMath} from "v4-core/src/libraries/TickMath.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 {OracleGate} from "src/OracleGate.sol";
import {IAbility, IAbilityBoard, SwapInfo} from "src/interfaces/IAbility.sol";

contract QuoteToken {
    uint8 public constant decimals = 18;
}

contract Ability is IAbility {
    function modifiers(SwapInfo calldata) external pure returns (uint256, uint256) {
        return (20_000, 10_000);
    }

    function name() external pure returns (string memory) {
        return "x";
    }
}

contract AttestationExpiresWhileQueuedTest is Test {
    uint160 internal constant HOOK_FLAGS = HookFlags.BEFORE_INITIALIZE | HookFlags.BEFORE_SWAP | HookFlags.AFTER_SWAP
        | HookFlags.BEFORE_SWAP_RETURN_DELTA | HookFlags.AFTER_SWAP_RETURN_DELTA;
    bytes32 internal constant QUESTION = keccak256("q");
    uint256 internal oracleKey = 0x0DAC1E;

    PoolManager internal manager;
    BossToken internal boss;
    QuoteToken internal imd;
    BossHook internal hook;
    AbilityBoard internal board;
    address internal keeper = makeAddr("keeper");

    function setUp() public {
        vm.warp(1_800_000_000);
        manager = new PoolManager(address(this));
        imd = new QuoteToken();
        boss = new BossToken();
        board = new AbilityBoard(keeper);
        bytes memory args = abi.encode(
            IPoolManager(address(manager)),
            address(boss),
            keeper,
            IAbilityBoard(address(board)),
            vm.addr(oracleKey),
            QUESTION,
            uint256(1 ether),
            uint24(129_000),
            address(imd)
        );
        bytes memory initCode = abi.encodePacked(type(BossHook).creationCode, args);
        (bytes32 salt, address predicted) = HookFlags.mineSalt(address(this), initCode, HOOK_FLAGS, 300_000);
        address addr;
        assembly ("memory-safe") {
            addr := create2(0, add(initCode, 32), mload(initCode), salt)
        }
        require(addr == predicted && addr != address(0), "hook deploy");
        hook = BossHook(addr);
        bool bossIsCurrency0 = address(boss) < address(imd);
        (Currency c0, Currency c1) = bossIsCurrency0
            ? (Currency.wrap(address(boss)), Currency.wrap(address(imd)))
            : (Currency.wrap(address(imd)), Currency.wrap(address(boss)));
        PoolKey memory key = PoolKey(c0, c1, 12_500, 60, IHooks(address(hook)));
        manager.initialize(key, TickMath.getSqrtPriceAtTick(bossIsCurrency0 ? -int24(129_000) : int24(129_000)));
    }

    /// @dev A real IMD attestation: validForSeconds = 86400 (docs/ORACLE-QUESTION.md).
    function _attest(uint256 answer) internal view returns (OracleGate.Attestation memory a, bytes memory sig) {
        a.requestId = keccak256(abi.encode("req", answer, block.timestamp));
        a.chainId = block.chainid;
        a.questionHash = QUESTION;
        a.answerType = 3;
        a.answer = abi.encode(answer);
        a.panelJobId = keccak256("panel");
        a.panelSize = 21;
        a.quorum = 15;
        a.agreed = 18;
        a.issuedAt = uint64(block.timestamp);
        a.expiresAt = uint64(block.timestamp + 86_400);
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(oracleKey, hook.attestationDigest(a));
        sig = abi.encodePacked(r, s, v);
    }

    function _propose() internal returns (uint256 id) {
        address ability = address(new Ability());
        vm.prank(keeper);
        board.propose(ability, "job");
        (id,) = board.pending();
        vm.warp(block.timestamp + 30 minutes); // the panel answers after the proposal
    }

    /// @notice Day 1: ability #1 accepted at T, activates T+12h. Ability #2 accepted at T+12h,
    /// pushed to T+36h by the 24h interval. The swarm's day-3 ability #3 is proposed and approved
    /// by the panel at about T+12h+1h; the attestation is valid for 24h, until about T+37h.
    /// It can only be relayed once #2 has activated, at T+36h; the relayer has a sliver of time,
    /// and any later block (here T+37h, i.e. well within the panel's daily cadence) finds the
    /// attestation expired. The approved ability can never be queued; the swarm must pay for a
    /// new oracle answer.
    function test_approvedAbilityCanBeRelayedWhileAnotherIsQueued() public {
        uint256 id1 = _propose();
        (OracleGate.Attestation memory a1, bytes memory s1) = _attest(id1 * 2 + 1);
        hook.acceptAbility(a1, s1);
        uint64 act1 = hook.queuedActivatesAt(); // T+12h

        vm.warp(act1 - 30 minutes);
        uint256 id2 = _propose(); // proposed, then answered at act1
        (OracleGate.Attestation memory a2, bytes memory s2) = _attest(id2 * 2 + 1);
        hook.acceptAbility(a2, s2); // relayed the moment #1 activates
        uint64 act2 = hook.queuedActivatesAt();
        assertEq(act2, act1 + 24 hours, "second activation pushed 24h after the first");

        // The swarm's next ability is proposed right away and the panel approves it 30 minutes later.
        uint256 id3 = _propose();
        (OracleGate.Attestation memory a3, bytes memory s3) = _attest(id3 * 2 + 1);
        assertLt(a3.expiresAt, act2 + 1 hours, "the 24h attestation outlives the queue lock by only 30 minutes");

        // Expected: an approved answer for the current pending id can be relayed when it is received
        // (its activation may be pushed by the 24h interval). Actual on this code: AbilityAlreadyQueued.
        (bool ok,) = address(hook).call(abi.encodeCall(hook.acceptAbility, (a3, s3)));
        if (!ok) {
            // Show the consequence: the lock lasts until act2, a relayer coming by one hour later
            // (within the panel's daily cadence) is refused for good, and the approval is wasted.
            vm.warp(act2 + 1 hours);
            assertGt(block.timestamp, a3.expiresAt);
            vm.expectRevert(OracleGate.InvalidAttestation.selector);
            hook.acceptAbility(a3, s3);
            fail("approved ability #3 could never be queued: locked out by queued #2 until its attestation expired");
        }
        assertEq(hook.acceptedId(), id3, "third ability accepted");
    }
}
```

### 4. Low: Pool initialization is permissionless: a stranger can bind the hook up to 300 ticks off the launch price and strand the single-sided seed

`src/BossHook.sol:289`

```
        if (tick < expectedTick - LAUNCH_TICK_TOLERANCE || tick > expectedTick + LAUNCH_TICK_TOLERANCE) {
```

PoolManager.initialize is permissionless and the pool key is fully pinned by the hook (currency pair, LP fee, tick spacing, hooks), so the first valid initialize for that key binds the hook for good (poolInitialized, launchTime, roundStart; no owner, no re-init). beforeInitialize accepts any sqrtPriceX96 whose tick lies within LAUNCH_TICK_TOLERANCE = 300 ticks of the launch tick (about +/-3% in price). If the hook is deployed in a different transaction from the initialize (launch.json says the sequence must be atomic; nothing in the code enforces it), anyone who sees the hook's code on-chain can initialize the pool at launchTick +/- 299 first. The launcher's own initialize then reverts in the PoolManager, and the planned single-sided seed range [launchTick + 60, maxTick] now contains the current price, so adding it needs IMD the launcher does not hold and the modifyLiquidity reverts. Not a loss of funds, and moot if deploy and initialize really share one transaction, but it is the one unprivileged action available between the two steps and the README only documents the race, not this consequence. Fix: restrict beforeInitialize's sender to the deployer or keeper, or tighten the tolerance to the tick-spacing snap, or guarantee the one-transaction launch in the deployment script. Merged from audit_economics and audit_permissions; both reproduce.

**Reproduction**

Deploy BossToken, AbilityBoard and BossHook (initialHp any, launchTickAbs 129000, BOSS as currency0) but do not initialize. A stranger EOA calls manager.initialize(PoolKey(BOSS, IMD, 12500, 60, hook), TickMath.getSqrtPriceAtTick(-129000 + 299)). Expected (launch.json): the pool opens at tick -129000 via the launcher's own initialize. Actual: beforeInitialize passes (tick is inside the 300-tick tolerance), hook.poolInitialized() == true at tick -128701; the launcher's manager.initialize(key, sqrtPriceAtTick(-129000)) reverts (pool already initialized); the launcher's lpRouter.modifyLiquidity(key, [-128940, maxUsableTick(60)], liquidityForAmount0(50,000,000 BOSS)) reverts because the range now straddles the current price and requires IMD. Verified with a Foundry test on this tree (test/scratch/Probe.t.sol, test_initRace_strangerBindsHookOffPrice, passes with the three assertions above).

### 5. Info: Exact-input buys and exact-output sells revert on any partial fill (price limit or exhausted liquidity) instead of filling partially; undocumented for integrators

`src/BossHook.sol:362`

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

When the paired currency is the specified side (exact-input buy, exact-output sell) the fee is diverted in beforeSwap from params.amountSpecified, so afterSwap requires the pool to have moved exactly gross - fee (buy, line 362) or gross (sell, line 365) of IMD, otherwise it reverts PartialFillNotSupported. Uniswap v4 fills partially whenever sqrtPriceLimitX96 is reached or liquidity runs out, so any such swap with a limit short of the full amount reverts, while the other two shapes (exact-output buy, exact-input sell) fill partially and are charged on the actual amount. The rule keeps the accounting exact and is not exploitable, but no test in the suite reaches either revert (every buy and sell uses the extreme limit and the invariant handler swallows reverts), and the README does not list it: a router or user that sets sqrtPriceLimitX96 as slippage protection on an exact-input buy gets a WrappedError(hook, afterSwap.selector, PartialFillNotSupported(), HookCallFailed()) instead of a partial fill. The Universal Router and V4Router use extreme limits plus amountOutMinimum, so the default path is unaffected. Suggested: add the two reverts to the Fees suite in both currency orderings and state the rule in the README's known limits, or charge these shapes in afterSwap on the actual IMD moved so partial fills are charged pro rata instead of refused. Merged from audit_math and audit_flow; both reproduce.

**Reproduction**

Launch fixture (initialHp 1 IMD). Exact-input buy of 50 IMD by alice with sqrtPriceLimitX96 = getSqrtPriceAtTick(currentTick + 120) in the buy direction (currentTick - 120 when the buy is zeroForOne). beforeSwap diverts fee = 1.25 IMD and hands the pool 48.75 IMD; the pool stops at the limit after taking only part of it, so -quoteDelta != gross - fee. Expected by a caller using the limit as slippage protection: a partial fill. Actual: the swap reverts; the revert data is the PoolManager's wrapped hook error 0x90bfb865 containing selector 0x7d617bb3 (PartialFillNotSupported). An exact-output buy with the same limit (amountSpecified = 1,000,000 BOSS) fills partially (quote delta about -2.61 IMD) and pays the fee on that amount. Verified in test/scratch/Probe.t.sol (test_partialFill_exactInBuy_reverts, test_partialFill_exactInBuy_revertReason).

### 6. Info: Trust assumptions: keeper plus the single immutable oracle signer can park the pot behind an effectively unkillable boss, and the relayer picks the activation moment

`src/libraries/BossMath.sol:11`

```
    uint256 internal constant MAX_MULT_BPS = 40_000;
```

Recorded as the agreed privileged powers, not as a permission bypass. There is no owner and no setter; the privileged actors are the keeper (sole proposer on the AbilityBoard, receiver of the 1% treasury) and the IMD oracle signer (immutable; one approval queues whatever the keeper proposed). (1) Within the clamp [MIN_MULT_BPS, MAX_MULT_BPS] an approved ability may return buyMult = 2500 and sellMult = 40000: one IMD of sells then heals sixteen times what one IMD of buys removes, so with maxHp up to 16 x initialHp (8,000 IMD at launch) a kill needs on the order of 32,000 IMD of net buys against any holder willing to sell. While such an ability is in force the pot (1.5% of all volume) keeps growing and nothing is lost, but no kill and no payout happens, and only the keeper can propose a replacement (earliest activation 24h after the previous one). (2) An ability receives s.trader and may legally key its multipliers on amount, hp, pot and timestamp; condition (e) of the oracle question (no dependence on the trader, no named addresses) is enforced only by the panel, so a signer compromise or panel mistake can give one address a 16x damage-efficiency edge over the pot for one 24h window at a time. (3) acceptAbility is permissionless and the activation time is block.timestamp + 12h at relay time (line 409); with 24h attestation validity the first relayer (in practice the keeper) chooses where in a roughly 24h window a known ability lands, which the 12h public delay does not neutralise. If the requester wants to bound these: cap the asymmetry between the two multipliers (for example sellMult <= 4 x buyMult), let any approved id replace a running ability without the keeper, and derive the activation from the attestation's issuedAt instead of the relay block. Merged from audit_economics and audit_permissions (three notes); all reproduce.

**Reproduction**

(1) Keeper proposes FixedAbility(2500, 40000); the oracle signs answer id*2+1; acceptAbility queues it; 12h later it is active. alice buys 0.4 IMD exact-input: damage(1, alice) == 0.1e18 (0.25x). bob sells exact-output 0.01 IMD: gross = 0.01e18 + 0.01e18 * 25000 / 975000 and hp rises by exactly 4 x gross (41025641025641024 wei). The defender out-heals the attacker 16:1 per IMD. (3) Keeper proposes FixedAbility(40000, 10000) at T0; an attestation with issuedAt = T0 + 1 and expiresAt = issuedAt + 86400 is signed; the holder relays it 20 hours later: queuedActivatesAt == relay time + 12h, not issuedAt + 12h. Both verified in test/scratch/Probe.t.sol (test_ability_16xAsymmetry, test_relayerChoosesActivation). (2) is a reading of IAbility.sol (SwapInfo carries trader) and BossHook._abilityMults; nothing in the hook or OracleGate can distinguish a trader-dependent ability.

### 7. Info: Test suite never separates the three identities (hookData label, router msgSender, tx.origin), so the two forfeit defects are invisible to the invariants

`test/invariant/BossHandler.sol:169`

```
        router.swap(key, p, PoolSwapTest.TestSettings(false, false), abi.encode(a));
```

The invariant handler and the BossFixture helpers always send abi.encode(actor) as hookData under vm.prank(actor, actor), so the hookData label, the router's msgSender() probe and tx.origin are the same address in every campaign call and in almost every unit test. The 'eligible damage == non-sellers' damage' invariant therefore holds by construction and cannot detect the two attribution findings above. There are also no contract traders (smart wallets, aggregators, baits), no zero-value or 1-wei sells, no swaps with a non-extreme price limit, no ability change during an invariant run, and no router that implements msgSender(). Add handler actions that (a) buy with a hookData address different from the signer, (b) sell through an intermediate contract, (c) sell one wei of BOSS, (d) swap with a tight sqrtPriceLimitX96, and (e) swap through a router exposing msgSender(); then restate the invariant as 'a wallet that claims a damage share never sold BOSS it bought under any identity'. Kept from audit_economics; the coverage gap is confirmed by reading the handler and by the two proofs above, which pass through code the suite never exercises.

**Reproduction**

forge test --match-path test/invariant/BossInvariant.t.sol: 64 runs, 4,096 calls, 0 reverts, all invariants hold. Every swap in test/invariant/BossHandler.sol (_swapStrict and _swapTry, lines 166-180) and in test/utils/BossFixture.sol (_swap, line 204) passes abi.encode(a) as hookData under vm.prank(a, a). Running test/scratch/SellForfeitBypass.t.sol (one buy with hookData = abi.encode(aliceLabel) followed by a sell from alice) and test/scratch/BaitForfeit.t.sol (a 1-wei sell through a contract) shows eligibleDamage diverging from the non-sellers' damage, a state the suite's invariant never reaches.

---

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