{"assessments":[],"deployments":[],"fuzz":[],"identity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"interpretation":"Records acceptance and evidence. Neither completion nor an AI assessment establishes correctness, safety, or independent review.","jobId":"be67d693-e50a-4b1f-b9bd-5a7db2c6040c","kind":"audit","nodes":[{"acceptedSubmissionHash":"13d79afb6a6c991979ca7b8f66f2a450c008d0abe75f8e2875d4fefe6fb5883a","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"52fdacff9950905d45fb8b59ae7c6883e7660e3d541c7ac75528ee1d82e29929","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"0fc1ceeb3cad39b0a7870fba5b658a1be37380f673ccc68e32c908a015c2c78b","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"924a1d44c9b25bdbecd041c041bc39e38bdcda53a272d6dbd280a3393f7d261e","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"2c51ef5d38ef84434230e8264dd77e217b52512dcca0f7c0dbe667760475a5de","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"}],"objective":"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.","parentJobId":null,"planHash":"e3afbe97255938be417d17e36f232653a3889d1e7d201f1ed53fa3492010d630","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"be67d693-e50a-4b1f-b9bd-5a7db2c6040c","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"52419","feedbackHash":"d08c7637fc7c56b179f76a4c700082f72d5dfaa798fa513aaf0096696874fbd4","nodeKey":"audit_economics","submissionHash":"13d79afb6a6c991979ca7b8f66f2a450c008d0abe75f8e2875d4fefe6fb5883a","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52149","feedbackHash":"aabbbfbbc2361bcddff466ad0b34b12616963124f8867b11675f938553ab4d18","nodeKey":"audit_flow","submissionHash":"52fdacff9950905d45fb8b59ae7c6883e7660e3d541c7ac75528ee1d82e29929","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51231","feedbackHash":"acea3102fe084c33420efd2e84074c085deaf0b22c10d4194a6cb1e884c9012e","nodeKey":"audit_judge","submissionHash":"0fc1ceeb3cad39b0a7870fba5b658a1be37380f673ccc68e32c908a015c2c78b","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52590","feedbackHash":"5c85843c9f87e1f2247a13e8346feb365f00b5b1e75378fb2c6c2674e0f2e659","nodeKey":"audit_math","submissionHash":"924a1d44c9b25bdbecd041c041bc39e38bdcda53a272d6dbd280a3393f7d261e","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52596","feedbackHash":"cd86e73a98f74859d889113deac7b6047b4f00b8d15ab48152f9f786ce97e5ed","nodeKey":"audit_permissions","submissionHash":"2c51ef5d38ef84434230e8264dd77e217b52512dcca0f7c0dbe667760475a5de","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"995ee6ca1c82bf128f5b2e515986ff7b1f9e5393fb06100892a682eec06e67f6","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"1507f63d3f1b973a","findings":[{"citation":"resolved","description":"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.","line":495,"path":"src/BossHook.sol","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\".","severity":"high","snippet":"        } else {\n            uint256 heal = BossMath.scale(gross, sellMult);","title":"Flash sells never forfeit pending buy credit: a killer buys unbounded damage credit for fees only and dilutes every other wallet's damage pool"},{"citation":"resolved","description":"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.","line":528,"path":"src/BossHook.sol","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\".","severity":"high","snippet":"        uint256 pd = _tload(T_PEND_DAMAGE);\n        uint256 pb = _tload(T_PEND_BOSS);\n        _tstore(T_PEND_DAMAGE, 0);\n        _tstore(T_PEND_BOSS, 0);\n        _credit(r, killer, pd, pb);","title":"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"},{"citation":"resolved","description":"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)`.","line":793,"path":"src/BossHook.sol","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.","severity":"low","snippet":"        damageClaimed[r][who] = true;\n        totalReserved -= amount;\n        _payout(who, amount);","title":"claimDamage(r, address(hook)) pays raw IMD into the hook itself, where it is stranded for good"},{"citation":"resolved","description":"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.","line":399,"path":"src/BossHook.sol","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).","severity":"low","snippet":"            if (isBuy) {\n                gross = specified;\n                if (uint256(uint128(-quoteDelta)) != gross - fee) revert PartialFillNotSupported();","title":"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"},{"citation":"resolved","description":"_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.","line":656,"path":"src/BossHook.sol","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).","severity":"low","snippet":"            address signer = tx.origin;\n            // Credit the signer already collected stays collected: it is not paid twice.\n            if (to != signer && !damageClaimed[kr][signer]) {","title":"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"},{"citation":"resolved","description":"_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.","line":630,"path":"src/BossHook.sol","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.","severity":"low","snippet":"        // A round this transaction closed: its credit still travels on later hops (a router that\n        // custodies the killing blow's BOSS and then delivers it). A closed round is never\n        // forfeited, so BOSS going into the pool skips this.\n        if (value == 0 || to == pm) return;","title":"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"},{"citation":"resolved","description":"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`.","line":729,"path":"src/BossHook.sol","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.","severity":"low","snippet":"    function _release(uint64 r, address from, uint256 value) internal returns (uint256 dmg, uint256 bossAmount) {\n        uint256 owned = bought[r][from];\n        if (owned == 0) return (0, 0);","title":"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"},{"citation":"resolved","description":"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.","line":419,"path":"src/BossHook.sol","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.","severity":"info","snippet":"    function _specifiedSideFee(SwapParams calldata params, bool isBuy) internal pure returns (uint256) {\n        uint256 specified = _abs(params.amountSpecified);\n        return isBuy ? specified * FEE_PIPS / PIPS : specified * FEE_PIPS / (PIPS - FEE_PIPS);","title":"Hook fee truncates to zero for trades under 40 wei of IMD while the trade still hits or heals the boss"},{"citation":"resolved","description":"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.","line":323,"path":"src/BossHook.sol","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.","severity":"info","snippet":"        if (tick < expectedTick - LAUNCH_TICK_TOLERANCE || tick > expectedTick + LAUNCH_TICK_TOLERANCE) {\n            revert WrongStartingPrice(sqrtPriceX96, tick, expectedTick);\n        }","title":"The first valid initialize binds the hook: anyone can front-run the launch and pick the start price within the 300-tick window"},{"citation":"resolved","description":"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).","line":591,"path":"src/BossHook.sol","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.","severity":"info","snippet":"        if (!ok) {\n            emit AbilityFailed(ability);\n            return (BPS, BPS);\n        }\n        return (BossMath.clampMult(b), BossMath.clampMult(s));","title":"Trust assumptions that bound the game: keeper plus oracle panel decide abilities, abilities see tx.origin, keeper and oracle signer are immutable"}],"hash":"0fc1ceeb3cad39b0a7870fba5b658a1be37380f673ccc68e32c908a015c2c78b","nodeId":"b3e0c7b3-1bc9-424b-b854-e7a1a6a05dce","outcome":"completed","summary":"Both proofs pass once the sell branch slices the pending credit (patched copy in /tmp only, the repository is untouched), so they hold the fix to the stated defect. The working tree has only the findings file and test/scratch, both allowed.\n\n**Outcome:** `.imd-findings.json` holds 10 findings, every one reproduced against this commit with a scratch Foundry test. Two highs carry self-contained proofs that fail now and pass with a minimal fix.\n\n**Kept, merged and ranked**\n\n- **High, flash sells never forfeit pending credit** (BossHook.sol line 495). A buy then flash sell in one unlock moves no BOSS token, so the forfeit never runs, and `_kill` credits the whole pending damage. Bob was credited 2.49 IMD of damage against a 1 IMD boss and alice's damage-pool share fell from 50% to 17%, for 0.15 IMD of fees. Proof: ProofFlashSell.\n- **High, kill credit is transaction-scoped** (line 528). A bundler batching its flash round trips around a victim's genuine kill takes the whole killer prize while the victim holds all of the blow's BOSS. Proof: ProofBundler. Both specialists' flow findings kept as separate mechanisms with separate fixes.\n- **Low, claimDamage for the hook address strands raw IMD** in the hook. Merged math and flow entries.\n- **Low, exact-input buys and exact-output sells revert on partial fill.** Merged three entries.\n- **Low, a kill whose BOSS stays as claims is credited to the relayer.** Merged permissions and economics entries.\n- **Low, custodian sells part of the blow and keeps the prize** because transfers into the pool skip `_trackKill`.\n- **Low, credit-free BOSS heals without forfeit**, kept with the note that the README partly documents the rule.\n- **Info:** fee dust under 40 wei, stranger initializing first within 300 ticks, and one merged trust-assumptions entry.\n\n**Dropped:** the economics claim that forfeit in BOSS versus heal in IMD lets an early buyer keep credit after a price rise. It describes the agreed proportional rule and its own reproduction showed no gap in the fixture, so it was not substantiated.\n\n**Not covered:** I did not re-audit the oracle replay path beyond reading it; the attestation binds chain, contract, question, monotonic id and expiry, and I found no replay. No fork tests ran.","treeHash":null,"usage":{"cachedInputTokens":2884112,"inputTokens":738,"model":"claude-fable-5-1","outputTokens":58482,"runtime":"claude","turns":36,"wallClockMs":787549}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"7eb063fcf0cf5626","findings":[{"citation":"resolved","description":"When the paired currency is the specified side (exact-input buy, exact-output sell) the 2.5% fee is pre-taken in beforeSwap on 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`; a user or integrator that sets a protective limit (the normal v4 slippage mechanism, used by several aggregators and by anyone quoting a max price) gets a hard revert instead of a partial fill, and the same happens if the pool's quote side is ever shallow for an exact-output sell. The other two shapes (exact-output buy, exact-input sell) take the fee in afterSwap from the unspecified side and fill partially without issue, so the behavior is inconsistent across the four shapes. Nobody loses funds (the whole transaction reverts), but a class of otherwise valid trades is blocked by the hook. Fix options: document that only MIN/MAX price limits are supported on these two shapes (the Uniswap app and Universal Router already do that), or compute the fee in afterSwap from the realized delta and return the difference via the unspecified-side delta so partial fills settle correctly.","line":399,"path":"src/BossHook.sol","reproduction":"Fixture: BossFixture (BOSS/IMD pool, launch tick -129000, single-sided seed). Input: alice calls PoolSwapTest.swap with SwapParams(zeroForOne = !bossIsCurrency0, amountSpecified = -100 ether, sqrtPriceLimitX96 = TickMath.getSqrtPriceAtTick(launchTick + 120)). Expected: a partial fill up to the limit, as v4 does for every other pool. Actual: the PoolManager reverts with WrappedError(hook, afterSwap.selector, PartialFillNotSupported(), HookCallFailed()). Control: the same limit with amountSpecified = +40_000_000 ether (exact-output buy) fills partially and succeeds, alice receives 2,695,819 BOSS. Verified with a scratch Foundry test (test_exactInBuy_withPriceLimit_revertsPartialFill / test_exactOutBuy_withPriceLimit_fillsPartially).","severity":"low","snippet":"                if (uint256(uint128(-quoteDelta)) != gross - fee) revert PartialFillNotSupported();","title":"Exact-input buys and exact-output sells revert on any partial fill, so a trader's own price limit (v4's slippage control) bricks those swap shapes"},{"citation":"resolved","description":"`_kill(signer)` credits the killing blow's damage (`_credit(r, killer, pd, pb)`, line 528) and the killer prize (`unclaimedKill[killer] += killerPrize`, line 541) to tx.origin, and the credit only moves to the real recipient when BOSS leaves the PoolManager by an ERC-20 transfer in the same transaction (`_arrive`). For an ERC-4337 account, an EIP-7702 delegated EOA executed by a sponsor, or any relayer-signed meta-transaction, tx.origin is the bundler/relayer. If the account's router keeps the swap output as ERC-6909 claims (a legitimate and gas-cheaper settlement path: `mint` instead of `take`, used by some solvers and by accounts that trade repeatedly), or if the BOSS is taken only in a later transaction, the bundler ends up as `results[r].killer` with the full prize and the full damage of the round, and the player who paid for the killing blow gets nothing. The README's Known limits state that 'bundlers that only sign cannot' keep the kill; that is only true when the BOSS is transferred out in the same transaction. The bundler needs no malicious code: it simply calls claimKill(bundler)/claimDamage(r, bundler) afterwards. Fix: at minimum correct the documentation and have the IMD front-end force a `take` to the account; a stronger fix is to credit the locker (the `sender` argument of afterSwap, i.e. the contract that called PoolManager.swap) rather than tx.origin when nothing arrives, or to keep unarrived kill credit claimable by the account that settles the claims in a later transaction.","line":377,"path":"src/BossHook.sol","reproduction":"Fixture: BossFixture (INITIAL_HP = 1 ether) plus ScriptRouter funded with 100 IMD. State: round 1, hp = 1 ether. Input: vm.prank(smartAccount, bundler); script.run([buy(1 ether), takeBossAsClaims(max)]) — the account's router buys with 1 IMD (gross 1 ether = 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 bundler. Actual: results(1).killer == bundler, unclaimedKill[bundler] == 6_750_000_000_000_000 (45% of the 15e15 pot), damage[1][bundler] == 1e18, damage[1][smartAccount] == 0. Verified with a scratch Foundry test (test_bundlerKeepsKill_whenAccountKeepsClaims).","severity":"low","snippet":"        address signer = tx.origin;","title":"A kill signed through a bundler or relayer whose BOSS never leaves the pool by transfer is credited to tx.origin, i.e. the bundler, contradicting the README's claim that bundlers that only sign cannot"},{"citation":"resolved","description":"Damage is credited in IMD paid (`BossMath.scale(gross, buyMult)`, line 486) and a sell heals the boss by the IMD gross received (line 495), but the forfeit charged when BOSS goes back into the pool is `damage * value / bought` (line 730): proportional to the BOSS count, not to the IMD the sale returns or the HP it heals. The intended invariant of the pot split ('the wallets by the damage each still holds') is that credit tracks net damage done; after a price move it does not. A wallet that bought early in a round holds BOSS with a low damage-per-BOSS; once the price has risen, selling a small slice of that BOSS heals the boss by far more HP than the credit it costs. The wallet recovers its whole stake in IMD, undoes its whole contribution to the fight, yet keeps a near-full claim on the damage pool, diluting every later buyer whose credit was paid at the higher price. No funds are stolen from the pot and the attacker still has BOSS at risk, so the severity is low, but it is a flow gap in the credit model that scales with intra-round volatility and is cheapest for the earliest buyers of each round. Possible fix (changes the agreed rule, needs a product decision): forfeit max(damage * value / bought, min(damage, healGross)) on sells into the pool, i.e. at least the HP the sale heals.","line":730,"path":"src/BossHook.sol","reproduction":"State: round r open, V buys 1,000 BOSS for 10 IMD at the launch price (damage[r][V] = 10 IMD, bought[r][V] = 1000 BOSS). Other buyers move the price up 10x within the round. Input: V sells 100 BOSS (exact-input) into the pool, receiving ~10 IMD gross. Expected under a net-damage model: V's credit falls by about the 10 IMD of HP the sale heals, to ~0. Actual (lines 495 and 730): hp rises by ~10 IMD (V's entire original damage is undone) while `_release` forfeits only 10 * 100 / 1000 = 1 IMD of credit; damage[r][V] stays 9 IMD, eligibleDamage[r] drops by only 1 IMD, and V holds 900 BOSS bought for 0 net IMD. A later buyer W who pays 10 IMD at the new price gets damage 10 and holds 100 BOSS; at the kill V (zero net damage, zero net cost) is paid 9/19 of what V+W share. In the fixture the mechanism is visible but flat because the 900M-BOSS single-sided pool barely moves: alice buys 0.05 IMD, bob buys 0.5 IMD, alice sells 10% of her BOSS: heal 4.756e15, forfeit 4.999e15 (test_creditVsNetDamage); the gap opens exactly as the price moves between the buy and the sell.","severity":"low","snippet":"        (dmg, bossAmount) = _slice(damage[r][from], owned, value);","title":"Forfeit is measured in BOSS share while the heal is measured in IMD, so a wallet whose BOSS appreciated can heal the boss by its entire original damage and keep most of its damage-pool share"},{"citation":"resolved","description":"Documented here as privileged powers rather than defects, with the concrete worst case each enables. (1) An activated ability receives `trader = tx.origin`, `amount`, `hp`, `pot` and `timestamp`; the hook only clamps the result to [0.25x, 4x]. An ability that favors one wallet (or a covert trigger such as a specific amount or timestamp parity) gives that wallet a 16x cheaper kill than everyone else: 1/16 of hp in IMD buys the killing blow and ~45% of the pot plus most of the damage pool. The only defense is condition (e) of the oracle question (no dependence on s.trader, no named addresses), enforced by the 21-seat panel off-chain; the hook cannot verify it. Post-Cancun ability code is immutable, but an ability whose result depends on its own storage set by a separate setter is also only caught by the panel (condition (d)). (2) The keeper is the only address that can propose, so it can stall abilities forever (game stays at 1x) and it collects 1% of all volume; `keeper` is immutable, so a lost key leaves `claimTreasury` pushing IMD to an unreachable address forever (treasuryOwed is pulled by anyone, but only to `keeper`). (3) `oracleSigner` is immutable: an IMD key rotation freezes abilities at the last active one. (4) The first `initialize` within ±300 ticks of the launch tick binds the hook; a stranger initializing first inside the tolerance cannot steal anything but can leave the launcher's single-sided seed range straddling the price (the launch must be atomic, as the manifest says). None of these is a permission bypass; they are the agreed design and should be visible in the launch notes.","line":566,"path":"src/BossHook.sol","reproduction":"(1) Deploy an ability `modifiers(s)` returning (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: a 16x asymmetry on a 500 IMD boss is 125 IMD vs 2,000 IMD. (2) State: keeper key lost. claimTreasury() succeeds and sends IMD to `keeper`; no other path can redirect it. (3) State: IMD rotates its attester. Every acceptAbility reverts InvalidSigner; activeAbility stays as is. (4) State: pool uninitialized; attacker calls PoolManager.initialize(key, getSqrtPriceAtTick(launchTick + 240)) before the launcher: beforeInitialize accepts (within 300 ticks), the launcher's own initialize then reverts PoolAlreadyInitialized and its planned seed range [launchTick + 60, max] is in range and requires IMD.","severity":"info","snippet":"                    trader: trader,","title":"Trust assumptions that bound the game: keeper + oracle panel decide abilities, abilities see tx.origin, and the oracle signer and keeper are immutable"}],"hash":"13d79afb6a6c991979ca7b8f66f2a450c008d0abe75f8e2875d4fefe6fb5883a","nodeId":"65ccebad-0e77-4015-a724-7b1d080bcc62","outcome":"completed","summary":"The review is complete and the findings file is in place at the repository root with four entries. No critical or high defects survived verification.\n\n**What I covered.** All of BossHook, BossToken, OracleGate, AbilityBoard and BossMath, read against the economic, invariant, flow-gap, entry-point and v4-composability checklists. I traced the four swap shapes through beforeSwap and afterSwap fee accounting, the pot split and reservation invariant, every path of the transfer-reported credit system including kill credit, closed-round moves, claims between unlocks and multi-kill transactions, the oracle attestation checks, and the ability call. The full suite of 311 tests passes, and my scratch probes ran against a fresh fixture.\n\n**Verified findings, most severe first.** All are low or informational.\n\n- **Price-limited exact-input buys and exact-output sells revert.** The fee is pre-taken on the full specified amount, so any partial fill hits the PartialFillNotSupported check in afterSwap. A trader using a sqrtPriceLimit as slippage control, or an aggregator that does, is blocked on these two shapes while the other two shapes fill partially. Reproduced with a limit 120 ticks away.\n- **A bundler or relayer keeps the kill when BOSS stays as claims.** The kill is credited to tx.origin and only moves when BOSS leaves the pool by transfer in the same transaction. For a 4337 account whose router mints ERC-6909 claims, the bundler ends up as the killer with the full prize and damage. Reproduced: the bundler holds the prize and the account holds nothing. This contradicts the README's statement that bundlers that only sign cannot keep the kill.\n- **Forfeit is by BOSS share but the heal is by IMD.** After a price rise inside a round, selling a small slice of early-bought BOSS heals the boss by the wallet's whole original damage while forfeiting only a sliver of credit. The credit no longer tracks net damage and dilutes later buyers. No principal is at risk, so it is a design-level flow gap.\n- **Trust assumptions documented as info.** Abilities see tx.origin and only the off-chain panel prevents a 16x cost asymmetry for a favored wallet. The keeper and oracle signer are immutable, and the first initialize inside the 300-tick tolerance binds the hook.\n\n**What I did not find.** No way for anyone to drain or lock the pot, steal another wallet's prize or damage outside the documented router-custody limit, replay an attestation, block trades through an ability, or make the treasury pay more than 1% of volume. The money invariant (claims equal pot plus treasury plus reserved) and the damage conservation per round hold on every path I traced, including the arithmetic underflow candidates in the transfer handler.\n\n**Limits of this review.** I could not check the live IMD token for transfer hooks or fee behavior offline. The mainnet fork tests were not run since there is no network. The scratch tests were removed after use as instructed.","treeHash":null,"usage":{"cachedInputTokens":2173389,"inputTokens":642,"model":"claude-fable-5-1","outputTokens":72924,"runtime":"claude","turns":21,"wallClockMs":1168481}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"629987aaf6c7aa88","findings":[{"citation":"resolved","description":"The forfeit rule (onBossTransfer -> _release) is keyed on `bought[round][from]`, the BOSS the sending wallet received from the pool in the open round. BOSS that was bought in an earlier round, or sits in any other wallet, carries `bought == 0`, so handing it to the pool heals the boss (afterSwap -> _play, sell branch) and forfeits nothing. A player therefore buys from wallet A (hit, damage credited to A) and sells the same amount of BOSS from wallet B that holds pre-round BOSS (heal, no forfeit). The boss is back where it was, the pot grew by the two hook fees, and A still holds the full damage credit toward the 45% damage pool. The README says the second fix was meant to close the 'second wallet dodged the forfeit' limit of the first fix; it only closes it for BOSS bought in the open round. Economics: each round trip costs 7.5% of volume in fees (3% of it to the pot) and buys 1x volume of damage credit without bringing the kill closer, so a whale can keep the round open with heals while accumulating the largest damage share, then kill when the pot is large enough. Fix within the design: forfeit on any BOSS entering the pool from a wallet that holds open-round credit (slice by balance, not by `bought`), or heal only in proportion to BOSS that carries no credit; both change the agreed rule and need a scope decision. No test covers a credit-free wallet healing after another wallet's buy.","line":729,"path":"src/BossHook.sol","reproduction":"Fixture (test/utils/BossFixture.sol, initial HP 1 IMD): round 1, alice buyExactIn 0.4 IMD -> hp 0.6, damage[1][alice] = 0.4e18, eligibleDamage[1] = 0.4e18, alice receives 153,123 BOSS. bob holds 153,123 BOSS from the fixture supply (never bought this round) and sellExactIn that amount. Expected (README rule 'BOSS handed back to the pool forfeits'): some credit leaves with the BOSS that undid the hit. Actual: hp = 0.98 IMD (healed back to 98%), damage[1][alice] still 0.4e18, damage[1][bob] = 0, eligibleDamage[1] unchanged, Forfeited never emitted. Verified with a scratch test on this commit.","severity":"low","snippet":"        if (owned == 0) return (0, 0);","title":"Sell-side heal from a wallet that bought nothing this round forfeits no credit: a player can undo their own damage and keep it"},{"citation":"resolved","description":"_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 (pool -> router: held[router] = 100%, prize moves to 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 (45% of the pot) stays on the router address, where claimKill(router) pays it to a contract that may have no way to forward 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'; here the player holds all of it and the router none. The route does not have to be malicious: any aggregator leg that sells part of a just-bought BOSS amount back to this pool before delivery (rebalancing, a fee taken in BOSS and sold) triggers it. Fix: in onBossTransfer, when `to == pm` and T_KILL_ROUND != 0, reduce held[from] by min(value, held[from]) (and recompute the largest holder), or run _trackKill(kr, from, pm, value) with the pool as a sink that never wins. Test P7 (review2) covers only the signer selling before arrival, not a custodian.","line":630,"path":"src/BossHook.sol","reproduction":"test/review/ReviewBase.sol setup, carol as tx.origin: bob buys 0.2 IMD (hp = 0.8). Router rr (holding no BOSS of its own) runs in ONE transaction: unlock{ buy 1.6 IMD (kills round 1), take all BOSS to rr }, unlock{ sell 60% of rr's BOSS back into the pool }, then boss.transferFrom(rr, alice, 100% of what rr still holds). Expected: results[1].killer == alice and unclaimedKill[alice] == 0.01215e18 (alice holds every remaining unit of the blow's BOSS, rr holds none). Actual on this commit: results[1].killer == rr, unclaimedKill[rr] = 12150000000000000, unclaimedKill[alice] = 0; alice holds 244,830 BOSS of the blow and 40% of its damage (0.32e18), rr keeps 60% of the damage (0.48e18) although it sold that BOSS. Verified with a scratch test.","severity":"low","snippet":"        if (value == 0 || to == pm) return;","title":"Kill-prize holder count ignores blow BOSS handed back to the pool, so a router that sells part of it before delivering keeps the killer prize"},{"citation":"resolved","description":"_kill credits the transaction's pending damage and the killer prize to tx.origin (afterSwap passes `signer = tx.origin` to _play/_kill) and _arrive only moves them to the wallet that receives the blow's BOSS from the pool in the same transaction. When that BOSS does not leave the pool in the transaction (kept as ERC-6909 claims, used to add liquidity, or 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, a Flashbots-style sponsor or an EIP-7702 gas sponsor, tx.origin is a third party with no relation to the player, so the player's whole kill (45% of the pot) and damage credit (eligible share of another 45%) belong to the bundler's EOA and can be claimed by anyone to that address via claimKill/claimDamage. The README states 'A relayer, bundler or solver signing for you changes nothing', which holds only when the BOSS leaves the pool in that transaction. The hook has no way to learn the intended beneficiary without hookData, which the design rejects for anti-spoofing reasons, so the minimal fix is documentary plus a defensive rule: when the pending credit is still unassigned at the end of the kill transaction it should be held under a neutral key (e.g. the round itself) and only assigned on arrival, or the launch material should tell smart-account users to always take BOSS out of the pool in the killing transaction.","line":656,"path":"src/BossHook.sol","reproduction":"test/review/ReviewBase.sol setup, round 1 with hp = 1 IMD. relayer (an EOA unrelated to the player) signs one transaction in which the ReviewRouter buys 2 IMD exact-in (kills the boss) and keeps the BOSS as ERC-6909 claims (rr.takeBossAsClaims). Expected per README: the player (the router's principal) is credited, the relayer gets nothing. Actual on this commit: results[1].killer == relayer, unclaimedKill[relayer] = 13500000000000000 (the whole killer prize), damage[1][relayer] = 1e18 (all eligible damage), damage[1][router] = 0, and nothing in a later transaction can move it (transient T_KILL_* is gone). Verified with a scratch test.","severity":"low","snippet":"            address signer = tx.origin;","title":"Kill credit and prize are parked on tx.origin: a relayed or bundled killing swap whose BOSS stays in the pool hands everything to the relayer EOA"},{"citation":"resolved","description":"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. Once the hook address is known (its CREATE2 address is mined and announced before the launch transaction), a third party can call initialize first with a tick up to 300 away. The launcher's own initialize then reverts in the PoolManager, and the single-sided seeding at [launchTick + 60, max] either becomes two-sided (price above the range) or starts 3% off. The README and launch.json already require the hook deployment and the pool initialize to be atomic; this finding records the concrete state the atomicity protects against and that nothing in the contract enforces it. Fix (optional): restrict beforeInitialize's `sender` to a deployer address passed to the constructor, or require the exact launch sqrtPrice instead of a window.","line":303,"path":"src/BossHook.sol","reproduction":"State: hook deployed, pool not yet initialized. Attacker calls manager.initialize(key, TickMath.getSqrtPriceAtTick(launchTick(bossIsCurrency0) + 299)) from any EOA. Expected: only the launch sequence can bind the pool. Actual: beforeInitialize returns its selector, poolInitialized = true, launchTime/roundStart = attacker's block, round = 1; the launcher's later initialize reverts with PoolAlreadyInitialized from the PoolManager.","severity":"info","snippet":"        if (poolInitialized) revert PoolAlreadyInitialized();","title":"First valid initialize binds the hook: anyone can front-run the launch and pick the start price within the 300-tick window"},{"citation":"resolved","description":"Documented powers, recorded separately from defects as the brief asks. (1) The keeper selects the ability (AbilityBoard.propose) and the IMD panel approves it; the hook only verifies the signature and the id, not the ability's behaviour, so a time-dependent ability (1x during review, 0.25x buys / 4x heals after activation) that the 15-of-21 panel misses makes the boss need 16x the net volume to die while the 1% treasury keeps accruing to the keeper. The clamp bounds the damage; nothing in the hook reads the ability's code. (2) 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 to switch to 1x. (3) In _kill the pending credit and the prize are assigned to tx.origin; for an ERC-4337 bundle, a Gelato/relayer transaction or any flow where the blow's BOSS never leaves the pool in that transaction (kept as ERC-6909 claims, LP deposit), the bundler EOA keeps the kill. The README lists this; the launch audience (smart-account users of the Uniswap app) should be told explicitly. None of these is a permission bypass; all are the agreed design.","line":591,"path":"src/BossHook.sol","reproduction":"(1) keeper proposes an ability whose modifiers() returns (10000,10000) while block.timestamp < activation + 1 and (2500, 40000) afterwards; panel approves id*2+1; after 12h every buy counts 0.25x and every sell 4x; swaps keep succeeding. (3) Fixture: alice kills through a router that keeps the BOSS as claims (ReviewRouter.takeBossAsClaims) in a transaction signed by relayer: results[r].killer == relayer and unclaimedKill[relayer] == killerPrize, alice gets nothing (test/BossHook.FollowBoss.t.sol test_killTakenAsClaims_staysWithTheSigner already shows the credit staying with the signer; no test runs it with a relayer as tx.origin).","severity":"info","snippet":"        return (BossMath.clampMult(b), BossMath.clampMult(s));","title":"Trust assumptions: keeper plus oracle panel control the ability within [0.25x, 4x]; immutable signer; kill credit parks on tx.origin when the blow's BOSS stays in the pool"},{"citation":"resolved","description":"When the paired currency is the specified side, the hook takes its fee in beforeSwap and afterwards requires the pool to have consumed exactly `gross - fee` (buy) or produced exactly `gross` (sell). A swap with a sqrtPriceLimitX96 that the pool reaches before the amount is exhausted, or a buy larger than the BOSS liquidity in range, reverts instead of filling partially; the mirrored shapes (exact-output buy, exact-input sell) fill partially and charge the fee on the actual amount. Only the trader's own transaction is affected, so this is not a denial of service of other users, but front-ends that send exact-input buys with a tight limit (the usual slippage protection shape) will see reverts rather than partial fills. Fix (optional): charge the fee in afterSwap from the actual specified amount consumed for these two shapes as well, dropping the equality check.","line":399,"path":"src/BossHook.sol","reproduction":"Fixture (BOSS is currency0, pool seeded single-sided from launchTick + 60): alice swaps exact-input 100 IMD (amountSpecified = -100e18) with sqrtPriceLimitX96 = getSqrtPriceAtTick(currentTick + 300) instead of MAX_SQRT_PRICE - 1. Expected: a partial fill of what those 300 ticks hold (about 28.4 IMD for 10.7M BOSS), fee on the consumed part. Actual on this commit: the PoolManager reverts with WrappedError(hook, afterSwap selector, PartialFillNotSupported()) and the whole swap fails. The same limit on an exact-output buy of 100,000,000 BOSS fills partially: 10,734,924.9 BOSS out for 28.376 IMD in, fee charged on the actual amount. Verified with a scratch test.","severity":"info","snippet":"                if (uint256(uint128(-quoteDelta)) != gross - fee) revert PartialFillNotSupported();","title":"Exact-input buys and exact-output sells revert on any partial fill, so a trader's own price limit always reverts the swap"}],"hash":"2c51ef5d38ef84434230e8264dd77e217b52512dcca0f7c0dbe667760475a5de","nodeId":"8c6114a1-7135-4771-85ab-e9114f5fa271","outcome":"completed","summary":"The findings file is in place at the repository root and is excluded from git by the harness's own `.git/info/exclude` entry, which is the expected arrangement. The scratch tests are removed, and the tree has no other changes.\n\n**Outcome: no critical or high defect found.** The pot, the claims and the oracle gate held up against every drain, lock, theft, replay and treasury-overpay path I traced. The six entries in `.imd-findings.json` are three lows and three infos, each with a concrete state and verified numbers.\n\n**Low findings**\n\n- **Second-wallet heal dodges the forfeit.** Forfeit is keyed on BOSS bought in the open round, so a wallet holding older BOSS can sell, heal the boss back to 98%, and the buying wallet keeps its full damage credit. This is the \"second wallet\" limit the credit-follows-the-BOSS fix was meant to close. Fix changes the agreed rule, so it needs a scope decision.\n- **Kill-prize holder count ignores BOSS sold back to the pool.** A router that custodies the blow, sells part back into this pool, then delivers the rest keeps the killer prize although the player ends up the only holder. One-line fix in the transfer handler.\n- **Relayed killing swap whose BOSS stays in the pool gives everything to the relayer EOA.** With a relayer as transaction signer and the blow kept as claims, the relayer holds the full prize and all eligible damage. This contradicts the README's \"a relayer, bundler or solver signing for you changes nothing\".\n\n**Info entries**\n\n- Anyone can front-run the launch's initialize within the 300-tick window. The atomic-launch requirement is the only guard.\n- Trust assumptions recorded separately: keeper plus panel pick the ability within the clamp, the signer is immutable, and nothing in the hook inspects ability code.\n- Exact-input buys and exact-output sells revert on any partial fill, so a tight price limit reverts rather than fills. Only the trader's own transaction is affected.\n\n**Coverage.** All of BossHook, BossToken, OracleGate, AbilityBoard, BossMath and the interfaces were read in full and traced against the Access Control, Asymmetry and Trust Gap guides plus the entry-point inventory. Checked and found sound: every claim path and its reentrancy ordering, the EIP-712 attestation binding and replay surface, the ability gas floor and clamp, fee arithmetic on all four swap shapes, and every subtraction in the transfer handler for a revert that could block BOSS transfers. The existing suite of 311 tests passes on this commit. Not run: the mainnet fork tests, which need a network, and Slither, which is not provided.","treeHash":null,"usage":{"cachedInputTokens":3637456,"inputTokens":994,"model":"claude-fable-5-1","outputTokens":64266,"runtime":"claude","turns":32,"wallClockMs":1002386}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"af1843f371022d4a","findings":[{"citation":"resolved","description":"Credit is supposed to follow the BOSS: a buy's damage waits in transient storage (`_pend`) until BOSS leaves the PoolManager, and BOSS handed back to the pool forfeits in proportion (`onBossTransfer`, `to == poolManager`). The forfeit is keyed only on BossToken transfers. A sell settled by Uniswap v4 flash accounting inside the same unlock as the buy moves no BOSS token at all (the buy's +BOSS delta nets against the sell's -BOSS delta), so `onBossTransfer` never runs, the sell branch of `_play` (lines 494-500) only heals and leaves T_PEND_DAMAGE / T_PEND_BOSS untouched. Each buy-then-flash-sell round trip therefore adds its full gross to the pending damage while the heal restores ~95% of the HP it removed (more with a sellMult > 1x ability, where HP does not drop at all). When the same transaction then lands the killing blow, `_kill` (line 528) credits the whole pending damage to the signer and adds it to `eligibleDamage[r]`, which `results[r].eligibleDamage` snapshots as the denominator of the damage pool. The killer thus buys arbitrary damage credit at ~7.5% of volume in fees (2.5% hook + 1.25% LP per side, 4x cheaper under a 4x buy ability) with no BOSS ever held, and dilutes every other wallet's share of the 45% damage pool toward zero. The same unforfeited pending credit lets a third party that batches calls around a victim's genuine killing blow in one transaction (an ERC-4337 bundler, tx.origin) walk away with the killer prize (see the companion finding). Fix: treat BOSS paid into the pool by a sell as handed back for this transaction's pending credit as well: in the sell branch of `_play`, slice T_PEND_DAMAGE/T_PEND_BOSS by min(bossAmount, T_PEND_BOSS) exactly as `_release` does for a wallet's `bought`, before healing (a sell paid by a wallet transfer already forfeited there; the only overlap is a buy kept as claims followed by a sell of older BOSS in the same unlock, which is acceptable). Alternatively, never credit at the kill more pending damage than the HP the transaction actually removed.","line":494,"path":"src/BossHook.sol","reproduction":"Fresh pool, initialHp 1 IMD. alice buys 0.5 IMD exact-in through PoolSwapTest (hp 0.5, damage[1][alice] 0.5, eligible 0.5). Attacker EOA calls a contract that, in ONE PoolManager unlock: 8 times {swap buy exact-in 0.1 IMD; swap sell exact-in the BOSS just received}, then swap buy exact-in 1 IMD (kills), take BOSS to the attacker, settle IMD. No BOSS token transfer happens during the round trips. Expected: damage[1][attacker] <= 0.5e18 (the HP that remained), eligibleDamage <= 1e18, claimableDamage(1, alice) >= damagePool/2. Actual (forge run): damage[1][attacker] = 1260627626612986904 (0.8 of round-trip credit + 0.46 kill), results[1].eligibleDamage = 1.76e18, alice's claimable share drops from 50% to 28% of the damage pool; the attacker spent only fees (< 0.1 IMD beyond the 1 IMD kill buy).","severity":"high","snippet":"        } else {\n            uint256 heal = BossMath.scale(gross, sellMult);\n            uint256 room = maxHp - hp;\n            if (heal > room) heal = room;\n            hp += heal;\n            emit Healed(r, signer, gross, heal, hp);\n        }","title":"Flash sells never forfeit pending buy credit: a killer inflates eligible damage for fees only and takes the other wallets' damage pool"},{"citation":"resolved","description":"Pending buy credit (T_PEND_*) and the killing credit (T_KILL_*) live in transient storage keyed by nothing but 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 relay multicall) an earlier party's unconsumed pending credit merges with the victim's blow: `_kill` records pd/pb as the sum, `_arrive` hands the victim only the proportional slice of the blow's BOSS it takes, and the party whose BOSS later leaves the pool receives the rest of the credit and, through `_trackKill` (`held` compares BOSS amounts), the killer prize via `_moveKill`. Because flash sells never forfeit pending credit (companion finding), the bundler can create 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, a buy slightly larger than the victim's blow taken to the bundler's wallet is counted as the blow's BOSS and out-holds the victim. The victim's operation executes exactly as signed and receives its BOSS, no code the victim chose is involved, so this is outside the README's 'whoever executes the killing swap for you' limit; the README states 'bundlers that only sign cannot' and 'a flash round trip that moves no BOSS changes nothing'. With a claims-held pre-buy instead of round trips the same ordering also lets a bundler skim the damage credit of any buy in its bundles (capital-backed, weaker). Fix: forfeit this transaction's pending credit on flash sells (companion finding) so round trips create no pending BOSS; 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 be combined with another party's blow.","line":522,"path":"src/BossHook.sol","reproduction":"Fresh pool, initialHp 1 IMD. carol buys 0.5 IMD in her own tx (hp 0.5). One transaction with tx.origin = bundler EOA runs three calls in order: (1) the bundler's executor, in one unlock, 8 x {buy 0.1 IMD exact-in; sell that BOSS exact-in} (no BossToken transfer, hp ends ~0.46); (2) the victim's smart account buys 0.6 IMD exact-in through 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] == killerPrize, unclaimedKill[bundlerWallet] == 0. Actual (forge run): results[1].killer == bundler wallet (0xE198...), unclaimedKill[victim] == 0, the bundler wallet holds the whole prize plus most of the blow's damage credit; it spent only fees on the round trips and holds 0.7 IMD of resellable BOSS.","severity":"high","snippet":"    function _kill(address killer) internal {\n        uint64 r = round;\n        uint256 pd = _tload(T_PEND_DAMAGE);\n        uint256 pb = _tload(T_PEND_BOSS);\n        _tstore(T_PEND_DAMAGE, 0);\n        _tstore(T_PEND_BOSS, 0);\n        _credit(r, killer, pd, pb);","title":"Kill credit is transaction-scoped: a bundler that batches flash round trips around a victim's genuine killing blow takes the killer prize"},{"citation":"resolved","description":"Credit follows the BOSS, and nothing stops BOSS carrying open-round credit from being sent to the hook contract itself (`onBossTransfer` only special-cases the PoolManager; `_trackKill` ignores `to == address(this)` for the prize but the damage credit still lands on `damage[r][address(this)]`). Once the round closes, `claimDamage(r, address(this))` is callable by anyone: it burns the hook's ERC-6909 claims and `poolManager.take`s IMD to the hook's own address. The hook has no code path that can move raw IMD (it only ever burns claims in `unlockCallback`), so that share of the damage pool is destroyed rather than left in the pool, and `totalReserved` accounting looks clean. Self-inflicted by whoever sends credited BOSS to the hook (a mistaken 'send to contract'), but the trigger is public and the loss is permanent. Fix: in `claimDamage`/`claimKill` revert when the beneficiary is `address(this)` (or redirect such a share back into `pot`), and optionally refuse credit moves to `address(this)` in `onBossTransfer` by treating the hook like the PoolManager (forfeit).","line":793,"path":"src/BossHook.sol","reproduction":"Fresh pool, initialHp 1 IMD. alice buys 0.5 IMD (damage[1][alice] = 0.5e18, bought 190857127786533396814818 BOSS). alice calls BossToken.transfer(address(hook), <her whole balance>): damage[1][hook] becomes 0.5e18, damage[1][alice] 0. bob buys 1 IMD and kills. Anyone calls hook.claimDamage(1, address(hook)): expected a revert (or the share staying in the pot); actual: the call succeeds, imd.balanceOf(address(hook)) == claimableDamage(1, hook) (half of the round's damage pool) as raw IMD with no function able to spend it, while ERC-6909 claims still equal pot + treasuryOwed + totalReserved. Reproduced in forge (test/scratch/HookStrand.t.sol passes with these assertions).","severity":"low","snippet":"        _payout(who, amount);","title":"claimDamage for the hook's own address pays raw IMD into the hook, where it is stranded"}],"hash":"52fdacff9950905d45fb8b59ae7c6883e7660e3d541c7ac75528ee1d82e29929","nodeId":"7124e3cf-97c4-4fa6-b8b5-a0a7150138b5","outcome":"completed","summary":"The review is complete. The findings file holds three substantiated defects, two of them with Foundry proofs that fail on the current code and pass under a minimal fix I tried and then reverted. No source or configuration file was changed.\n\n**What I found**\n\n- **High, BossHook.sol:494 (sell branch of `_play`).** A sell settled by v4 flash accounting inside the same unlock as the buy moves no BOSS token, so the forfeit in `onBossTransfer` never runs and the transaction's pending buy credit survives. Repeated buy-then-flash-sell round trips add their full gross to pending damage while the heal restores about 95% of the HP. When the same transaction lands the killing blow, `_kill` credits all of it to the signer and it enters the damage-pool denominator. In the proof the killer removes at most 0.5 IMD of HP but is credited 1.26 IMD, and the honest player's share of the damage pool falls from 50% to 28%. The cost is fees only, and a sell ability above about 1.05x makes the inflation unbounded.\n\n- **High, BossHook.sol:522 (`_kill`).** Pending and killing credit are keyed only by the transaction. In an ERC-4337 bundle or relay multicall, the bundler's own round trips before a victim's genuine killing blow, plus a buy slightly larger than the blow after it, make the bundler's wallet the largest holder of \"the blow's BOSS\" and `_moveKill` hands it the killer prize. The victim's operation executes exactly as signed and receives its BOSS, which contradicts the README's claim that bundlers that only sign cannot take the kill. Proof shows the bundler wallet as the recorded killer with the whole prize.\n\n- **Low, BossHook.sol:793.** BOSS carrying credit sent to the hook's own address makes the hook a damage claimant. Anyone can then call `claimDamage` for the hook, which takes raw IMD into the hook where no code path can move it. Reproduced in a scratch test.\n\n**Suggested fix, verified against both proofs:** in the sell branch of `_play`, slice this transaction's pending damage and BOSS by the BOSS the sell pays in, exactly as `_release` does for a wallet's `bought`. For the second finding, additionally record the `sender` seen in `afterSwap` and void rather than merge pending credit when a different sender swaps.\n\n**Coverage and limits.** I read all eight source files, the fixture, routers, both prior review suites and the invariant handler, and ran the full non-invariant suite (310 passed). I checked fee math on all four swap shapes, the beforeSwap and afterSwap delta semantics against the vendored v4-core, claim accounting and reentrancy, the oracle gate's replay and malleability protections, ability gas and return handling, and the pool pin. I found no way to drain or lock the pot, replay an attestation, block trades through an ability, or overpay the treasury. Not verifiable offline: the IMD oracle's `issuedAt` semantics relative to the board's `proposedAt` check, the mainnet addresses in launch.json, and IMD being a plain ERC-20.","treeHash":null,"usage":{"cachedInputTokens":3988247,"inputTokens":610,"model":"claude-fable-5-1","outputTokens":109334,"runtime":"claude","turns":49,"wallClockMs":1651128}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"99e4819277ea59fc","findings":[{"citation":"resolved","description":"Boundary x invariant. For the two swap shapes where the paired currency is the specified side (exact-input buy, exact-output sell) the hook charges its 2.5% fee in beforeSwap from params.amountSpecified, before the pool has swapped. afterSwap then requires the pool's quote delta to equal exactly `gross - fee` (line 399) or `gross` (line 402) and reverts PartialFillNotSupported otherwise. Uniswap v4 itself fills up to sqrtPriceLimitX96 (or until liquidity runs out) and returns the partial delta; the other two shapes of this very hook (exact-output buy, exact-input sell, lines 404-414) do accept partial fills because they compute the fee from the realised delta. So the swap outcome depends on which shape and which price limit the trader or integrator used: a limit-bounded exact-input buy that would partially fill on any plain v4 pool reverts here, and an exact-output sell that asks for more IMD than the pool holds reverts instead of paying what is available. A front-runner who pushes the price past a victim's limit turns the victim's partial fill into a full revert (gas lost, no fill). This is not attacker-profitable, but it blocks a legitimate trade shape and makes the four swap shapes behave asymmetrically. Fix (design decision needed): the specified-side fee cannot be refunded from afterSwap (return deltas there are unspecified-side only), so either (a) document that these two shapes must be sent with the full-range price limit, as the Universal Router does, and have integrators use the other two shapes for bounded fills, or (b) charge all four shapes in afterSwap from the realised delta by moving the fee of these two shapes to the unspecified side (which would make it a BOSS fee, contrary to the IMD-only design), or (c) keep the revert and name it in the README's known limits.","line":399,"path":"src/BossHook.sol","reproduction":"Fixture pool (BossFixture: BOSS/IMD, launch tick, 900M BOSS single-sided). Read the current tick (getSlot0) and set sqrtPriceLimitX96 = TickMath.getSqrtPriceAtTick(tick + 120) (BOSS is currency0, buy is oneForZero). Input A: swapRouter.swap(key, SwapParams(zeroForOne=false, amountSpecified=-100e18, limit)) from alice. Expected (v4 semantics, and what the exact-output shape does): a partial fill up to the limit, fee on the realised amount. Actual: the whole swap reverts, WrappedError(hook, afterSwap, PartialFillNotSupported() = 0x7d617bb3, HookCallFailed). Input B, same limit, exact-output buy amountSpecified=+1_000_000e18: succeeds (alice receives BOSS). Input C: after buyExactIn(bob, 0.5e18) the PoolManager holds 0.5 IMD; alice (holding 100M BOSS) sends an exact-output sell amountSpecified=+10e18 with the full-range limit: reverts PartialFillNotSupported (line 402, same selector 0x7d617bb3 in the WrappedError), while an exact-input sell of the same 100M BOSS succeeds and pays alice the 0.469 IMD the pool can give. Probes used (not kept): test_probe_partialFillExactInBuy (ok=false, 0x7d617bb3 in the revert data; the exact-output buy with the same limit ok=true) and test_probe_sellExactOutTooMuch (exact-out sell ok=false, exact-in sell ok=true).","severity":"low","snippet":"                if (uint256(uint128(-quoteDelta)) != gross - fee) revert PartialFillNotSupported();","title":"Exact-input buys and exact-output sells revert on any partial fill instead of filling up to the price limit"},{"citation":"resolved","description":"Boundary: sentinel branch. onBossTransfer treats the PoolManager as the only sink (to == pm forfeits) 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, closed round) 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, so it is lost for good, and the accounting invariant the tests assert (`hook must hold no raw quote token`, BossFixture.assertAccounting) is broken on chain. In the native-ETH variant the same claim reverts instead (the hook has no receive), so the reserve stays locked but the claim can never be paid. Impact: only the value of credit someone sent to the hook (mistaken transfers, a router sweeping leftovers to the hook) is affected, nobody else's share changes, so low. Fix: in onBossTransfer treat `to == address(this)` like the pool (forfeit the open-round credit, skip the closed-round move) and in _arrive skip crediting when `to == address(this)`; additionally refuse claimDamage/claimKill for `who == address(this)`.","line":621,"path":"src/BossHook.sol","reproduction":"Fixture (INITIAL_HP = 1 IMD). 1) buyExactIn(alice, 0.5e18) -> damage[1][alice] = 0.5e18, bought = B. 2) vm.prank(alice); boss.transfer(address(hook), B) -> damage[1][hook] = 0.5e18 (DamageMoved alice->hook). 3) buyExactIn(bob, 2e18) kills boss 1: results[1].damagePool = 16_875_000_000_000_000, eligibleDamage = 1e18. 4) claimableDamage(1, address(hook)) = 8_437_500_000_000_000. 5) anyone calls hook.claimDamage(1, address(hook)): succeeds, quoteToken.balanceOf(address(hook)) = 8_437_500_000_000_000 raw IMD, totalReserved reduced by the same, no function can move it. Expected: credit sent to the hook is forfeited (or at least never paid into the hook). Probe (not kept) logged damage hook 500000000000000000, claimable 8437500000000000, raw quote in hook 8437500000000000.","severity":"low","snippet":"                damage[r][to] += dmg;","title":"BOSS sent to the hook address keeps its damage credit, and claimDamage(r, hook) pays that share as raw IMD into the hook where it is stuck"},{"citation":"resolved","description":"Boundary x precision (zero-rounding). 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; 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, a 38-wei exact-output sell heals 38. The invariant 'every trade pays 2.5% to pot and treasury' therefore fails at the boundary. The amounts are far below gas cost, so there is no economic exploit; recorded for completeness of the math pass. Fix if wanted: round the fee up (mulDivUp) for the specified-side shapes, which keeps the >= 2.5% guarantee with at most 1 wei over.","line":419,"path":"src/BossHook.sol","reproduction":"Fixture, fresh pool. buyExactIn(alice, 39): BalanceDelta quote = -39, BOSS = 15_111_115; hookQuoteClaims() = 0; hook.pot() = 0; hook.treasuryOwed() = 0; hook.hp() = 999_999_999_999_999_961 (1e18 - 39); hook.damage(1, alice) = 39. Expected a fee of 2.5% (i.e. >= 1 wei) on a scored trade.","severity":"info","snippet":"        return isBuy ? specified * FEE_PIPS / PIPS : specified * FEE_PIPS / (PIPS - FEE_PIPS);","title":"Hook fee truncates to zero for trades under 40 wei of IMD while the trade still hits or heals the boss"}],"hash":"924a1d44c9b25bdbecd041c041bc39e38bdcda53a272d6dbd280a3393f7d261e","nodeId":"ee96239c-05be-4564-9ce5-78507797ed9e","outcome":"completed","summary":"The review is complete. The findings file is valid, every snippet matches its source line, the scratch probes are removed, and no tracked file was changed.\n\n**Outcome.** The math, boundary and numerical-gap passes over BossHook, BossMath, BossToken, AbilityBoard and OracleGate found no way to drain or lock the pot, steal another wallet's prize, replay an attestation, or overpay the treasury. Three substantiated defects are recorded in `.imd-findings.json`, none critical or high, so no proof test files were attached.\n\n- **Low, partial fills revert on two swap shapes.** Exact-input buys and exact-output sells charge the fee in beforeSwap from the specified amount, so afterSwap requires an exact fill and reverts with PartialFillNotSupported otherwise. A 100 IMD exact-input buy with a price limit 120 ticks away reverts, while the exact-output shape with the same limit fills. An exact-output sell for 10 IMD against a pool holding 0.5 IMD reverts, while the exact-input sell pays what the pool has. A front-runner who moves the price past a victim's limit turns a partial fill into a wasted revert. The fix is a design choice, since a specified-side fee cannot be refunded from afterSwap.\n- **Low, credit on the hook address can be claimed into the hook.** BOSS transferred to the hook keeps its damage credit, and the permissionless claimDamage pays that share as raw IMD into the hook, where no function can move it. Concrete numbers are in the file. Only the sender of that BOSS loses value, but it breaks the invariant that the hook holds no raw quote token.\n- **Info, zero fee below 40 wei.** A 39 wei buy pays no hook fee but still lowers HP by 39 and credits 39 of damage. Economically irrelevant, recorded for completeness of the zero-rounding pass.\n\n**What held up under inspection.** Fee math is consistent at 2.5% of gross on all four shapes and the 60/40 pot-treasury split never rounds in the treasury's favour. The kill split conserves the pot, the damage-pool denominator is always the sum of outstanding damage so claims never exceed the pool, and the \"nobody eligible\" branch is unreachable because the killing blow is itself credited. The kill-credit and prize-following arithmetic has no underflow path across partial arrivals, signer hops and claims between unlocks. The oracle gate binds chain, consumer, question, panel, quorum, low-s signatures and expiry, and the board's monotonic id plus the issuedAt-after-proposal rule prevent replay of an older approval. The ability call is gas-floored, clamped and cannot block a trade. The remaining risks are the trust limits the README already names, chiefly that whoever executes the killing swap for a player can keep the kill.\n\n**Coverage note.** The fork test suite was not run (no RPC in this environment). The invariant and fuzz suites ran at their configured depth and passed.","treeHash":null,"usage":{"cachedInputTokens":3618490,"inputTokens":642,"model":"claude-fable-5-1","outputTokens":78648,"runtime":"claude","turns":56,"wallClockMs":1337326}}],"verification":[]}