{"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":"2f96ed9f-2185-4cfd-88b9-b1df9fde54e9","kind":"audit","nodes":[{"acceptedSubmissionHash":"f3592ffb382c17131972ba0c62cd17cf5efdb43c93a385dc63bd53d35c046424","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":"2ad3a6afe8ba12f6a92e27555904c7edb0f2760e4078f529f1a7ed39c0e1c752","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":"16779b889f5c1c6549bf12d7f176ca3647c27732cb50fc6a2d2cae48f3329143","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":"71d4c9556e8a877703cec94638c17510c69151c715143c245ae8ff1433a440b9","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":"37a945217762469c0feff13a2ddda63263ae01f70c4ada1a13ba0d8a175efde3","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 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.","parentJobId":null,"planHash":"0cbd0b01da2134e4fcbf0be48ee3f36f3bec22eecdbf1bfc4c2383a023841309","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"2f96ed9f-2185-4cfd-88b9-b1df9fde54e9","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":"52258","feedbackHash":"77fd4c93f2e342da5ed9039321b717398ce2ad377df7ea46e6cf3fc5fb7689c2","nodeKey":"audit_economics","submissionHash":"f3592ffb382c17131972ba0c62cd17cf5efdb43c93a385dc63bd53d35c046424","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52590","feedbackHash":"5cda5ecd4618b7144cc31f42ad531b8328b8190705ce8db5e8cdd560745f2e1d","nodeKey":"audit_flow","submissionHash":"2ad3a6afe8ba12f6a92e27555904c7edb0f2760e4078f529f1a7ed39c0e1c752","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51231","feedbackHash":"ebd47b7d3c1905c7b56975b62292d8353a14084128d8bd8a124f1b87128baec0","nodeKey":"audit_judge","submissionHash":"16779b889f5c1c6549bf12d7f176ca3647c27732cb50fc6a2d2cae48f3329143","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52021","feedbackHash":"bda9be45e0882fdf25a44997a5c649ad447e80cba4aec26b24d34910cf0bc38a","nodeKey":"audit_math","submissionHash":"71d4c9556e8a877703cec94638c17510c69151c715143c245ae8ff1433a440b9","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52419","feedbackHash":"da9418ac1accf116f7fb7af195367ff478110309964e67db8da3e06f651ee682","nodeKey":"audit_permissions","submissionHash":"37a945217762469c0feff13a2ddda63263ae01f70c4ada1a13ba0d8a175efde3","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"bde5548fc108eb35ac57cf7018c4bd1b46627baee7da2e5acecf6bd7c22f68f8","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"1507f63d3f1b973a","findings":[{"citation":"resolved","description":"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.","line":457,"path":"src/BossHook.sol","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').","severity":"medium","snippet":"                sold[r][trader] = true;","title":"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)"},{"citation":"resolved","description":"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.","line":342,"path":"src/BossHook.sol","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').","severity":"medium","snippet":"        address trader = isBuy ? _resolveTrader(sender, hookData) : tx.origin;","title":"Sell-forfeit rule is void for a single signer: buys are credited to a free hookData label while sells only mark tx.origin"},{"citation":"resolved","description":"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.","line":404,"path":"src/BossHook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\n\nimport {HookFlags} from \"src/HookFlags.sol\";\nimport {BossToken} from \"src/BossToken.sol\";\nimport {BossHook} from \"src/BossHook.sol\";\nimport {AbilityBoard} from \"src/AbilityBoard.sol\";\nimport {OracleGate} from \"src/OracleGate.sol\";\nimport {IAbility, IAbilityBoard, SwapInfo} from \"src/interfaces/IAbility.sol\";\n\ncontract QuoteToken {\n    uint8 public constant decimals = 18;\n}\n\ncontract Ability is IAbility {\n    function modifiers(SwapInfo calldata) external pure returns (uint256, uint256) {\n        return (20_000, 10_000);\n    }\n\n    function name() external pure returns (string memory) {\n        return \"x\";\n    }\n}\n\ncontract AttestationExpiresWhileQueuedTest is Test {\n    uint160 internal constant HOOK_FLAGS = HookFlags.BEFORE_INITIALIZE | HookFlags.BEFORE_SWAP | HookFlags.AFTER_SWAP\n        | HookFlags.BEFORE_SWAP_RETURN_DELTA | HookFlags.AFTER_SWAP_RETURN_DELTA;\n    bytes32 internal constant QUESTION = keccak256(\"q\");\n    uint256 internal oracleKey = 0x0DAC1E;\n\n    PoolManager internal manager;\n    BossToken internal boss;\n    QuoteToken internal imd;\n    BossHook internal hook;\n    AbilityBoard internal board;\n    address internal keeper = makeAddr(\"keeper\");\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        manager = new PoolManager(address(this));\n        imd = new QuoteToken();\n        boss = new BossToken();\n        board = new AbilityBoard(keeper);\n        bytes memory args = abi.encode(\n            IPoolManager(address(manager)),\n            address(boss),\n            keeper,\n            IAbilityBoard(address(board)),\n            vm.addr(oracleKey),\n            QUESTION,\n            uint256(1 ether),\n            uint24(129_000),\n            address(imd)\n        );\n        bytes memory initCode = abi.encodePacked(type(BossHook).creationCode, args);\n        (bytes32 salt, address predicted) = HookFlags.mineSalt(address(this), initCode, HOOK_FLAGS, 300_000);\n        address addr;\n        assembly (\"memory-safe\") {\n            addr := create2(0, add(initCode, 32), mload(initCode), salt)\n        }\n        require(addr == predicted && addr != address(0), \"hook deploy\");\n        hook = BossHook(addr);\n        bool bossIsCurrency0 = address(boss) < address(imd);\n        (Currency c0, Currency c1) = bossIsCurrency0\n            ? (Currency.wrap(address(boss)), Currency.wrap(address(imd)))\n            : (Currency.wrap(address(imd)), Currency.wrap(address(boss)));\n        PoolKey memory key = PoolKey(c0, c1, 12_500, 60, IHooks(address(hook)));\n        manager.initialize(key, TickMath.getSqrtPriceAtTick(bossIsCurrency0 ? -int24(129_000) : int24(129_000)));\n    }\n\n    /// @dev A real IMD attestation: validForSeconds = 86400 (docs/ORACLE-QUESTION.md).\n    function _attest(uint256 answer) internal view returns (OracleGate.Attestation memory a, bytes memory sig) {\n        a.requestId = keccak256(abi.encode(\"req\", answer, block.timestamp));\n        a.chainId = block.chainid;\n        a.questionHash = QUESTION;\n        a.answerType = 3;\n        a.answer = abi.encode(answer);\n        a.panelJobId = keccak256(\"panel\");\n        a.panelSize = 21;\n        a.quorum = 15;\n        a.agreed = 18;\n        a.issuedAt = uint64(block.timestamp);\n        a.expiresAt = uint64(block.timestamp + 86_400);\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(oracleKey, hook.attestationDigest(a));\n        sig = abi.encodePacked(r, s, v);\n    }\n\n    function _propose() internal returns (uint256 id) {\n        address ability = address(new Ability());\n        vm.prank(keeper);\n        board.propose(ability, \"job\");\n        (id,) = board.pending();\n        vm.warp(block.timestamp + 30 minutes); // the panel answers after the proposal\n    }\n\n    /// @notice Day 1: ability #1 accepted at T, activates T+12h. Ability #2 accepted at T+12h,\n    /// pushed to T+36h by the 24h interval. The swarm's day-3 ability #3 is proposed and approved\n    /// by the panel at about T+12h+1h; the attestation is valid for 24h, until about T+37h.\n    /// It can only be relayed once #2 has activated, at T+36h; the relayer has a sliver of time,\n    /// and any later block (here T+37h, i.e. well within the panel's daily cadence) finds the\n    /// attestation expired. The approved ability can never be queued; the swarm must pay for a\n    /// new oracle answer.\n    function test_approvedAbilityCanBeRelayedWhileAnotherIsQueued() public {\n        uint256 id1 = _propose();\n        (OracleGate.Attestation memory a1, bytes memory s1) = _attest(id1 * 2 + 1);\n        hook.acceptAbility(a1, s1);\n        uint64 act1 = hook.queuedActivatesAt(); // T+12h\n\n        vm.warp(act1 - 30 minutes);\n        uint256 id2 = _propose(); // proposed, then answered at act1\n        (OracleGate.Attestation memory a2, bytes memory s2) = _attest(id2 * 2 + 1);\n        hook.acceptAbility(a2, s2); // relayed the moment #1 activates\n        uint64 act2 = hook.queuedActivatesAt();\n        assertEq(act2, act1 + 24 hours, \"second activation pushed 24h after the first\");\n\n        // The swarm's next ability is proposed right away and the panel approves it 30 minutes later.\n        uint256 id3 = _propose();\n        (OracleGate.Attestation memory a3, bytes memory s3) = _attest(id3 * 2 + 1);\n        assertLt(a3.expiresAt, act2 + 1 hours, \"the 24h attestation outlives the queue lock by only 30 minutes\");\n\n        // Expected: an approved answer for the current pending id can be relayed when it is received\n        // (its activation may be pushed by the 24h interval). Actual on this code: AbilityAlreadyQueued.\n        (bool ok,) = address(hook).call(abi.encodeCall(hook.acceptAbility, (a3, s3)));\n        if (!ok) {\n            // Show the consequence: the lock lasts until act2, a relayer coming by one hour later\n            // (within the panel's daily cadence) is refused for good, and the approval is wasted.\n            vm.warp(act2 + 1 hours);\n            assertGt(block.timestamp, a3.expiresAt);\n            vm.expectRevert(OracleGate.InvalidAttestation.selector);\n            hook.acceptAbility(a3, s3);\n            fail(\"approved ability #3 could never be queued: locked out by queued #2 until its attestation expired\");\n        }\n        assertEq(hook.acceptedId(), id3, \"third ability accepted\");\n    }\n}","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').","severity":"low","snippet":"        if (queuedAbility != address(0)) revert AbilityAlreadyQueued();","title":"Single ability queue slot locks out a fresh oracle approval for up to 24 hours, long enough for the attestation to expire"},{"citation":"resolved","description":"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.","line":289,"path":"src/BossHook.sol","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).","severity":"low","snippet":"        if (tick < expectedTick - LAUNCH_TICK_TOLERANCE || tick > expectedTick + LAUNCH_TICK_TOLERANCE) {","title":"Pool initialization is permissionless: a stranger can bind the hook up to 300 ticks off the launch price and strand the single-sided seed"},{"citation":"resolved","description":"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.","line":362,"path":"src/BossHook.sol","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).","severity":"info","snippet":"                if (uint256(uint128(-quoteDelta)) != gross - fee) revert PartialFillNotSupported();","title":"Exact-input buys and exact-output sells revert on any partial fill (price limit or exhausted liquidity) instead of filling partially; undocumented for integrators"},{"citation":"resolved","description":"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.","line":11,"path":"src/libraries/BossMath.sol","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.","severity":"info","snippet":"    uint256 internal constant MAX_MULT_BPS = 40_000;","title":"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"},{"citation":"resolved","description":"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.","line":169,"path":"test/invariant/BossHandler.sol","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.","severity":"info","snippet":"        router.swap(key, p, PoolSwapTest.TestSettings(false, false), abi.encode(a));","title":"Test suite never separates the three identities (hookData label, router msgSender, tx.origin), so the two forfeit defects are invisible to the invariants"}],"hash":"16779b889f5c1c6549bf12d7f176ca3647c27732cb50fc6a2d2cae48f3329143","nodeId":"d705e8c7-2279-4c90-b561-ae5b229c1835","outcome":"completed","summary":"Review complete. The findings file at `.imd-findings.json` holds seven findings, three of them with Foundry proofs that fail on the current code and pass once the defect is fixed. The source tree is unchanged, and `git status --porcelain` is clean.\n\n**What I kept, after reproducing each against the code**\n\n- **Medium, third-party forfeit.** Sells are keyed on tx.origin and the forfeit runs before the amount is checked, so any contract a player calls can sell 1 wei of BOSS (0 IMD out, 0 fee, 0 heal) and erase that player's whole damage share. Four specialists reported variants of this; I merged them into one finding with my own self-contained proof. A sell with no BOSS at all also marks the wallet sold at the launch tick.\n- **Medium, hookData label bypass.** Buys are credited to any address named in hookData while sells only mark tx.origin, so one signer buys under a label, sells everything, and the label keeps its non-seller share. Two specialists reported this; merged, with a proof whose assertions hold under any coherent fix.\n- **Low, ability queue lockout.** One queue slot plus a 24 hour push can keep a fresh oracle approval out until its attestation expires. The specialist's proof fails for the stated reason, so it stays attached.\n- **Low, permissionless pool initialization** inside the 300 tick tolerance, which also strands the single-sided seed. Merged from two reports and reproduced.\n- **Info, PartialFillNotSupported** on price-limited exact-input buys and exact-output sells, undocumented and untested. Merged from two reports.\n- **Info, trust assumptions.** Keeper plus oracle signer can run a 0.25x buy / 4x sell ability that parks the pot, and the relayer picks the activation moment. Merged from three notes and reproduced numerically.\n- **Info, test coverage.** The suite never separates hookData, msgSender and tx.origin, so the invariants cannot see the two attribution defects.\n\n**What I dropped and why**\n\n- Fee truncation below 40 wei: true but no economic effect.\n- Rounding dust locked in the hook: already listed in the README's known limits.\n- Killing-blow mempool race: already listed in the README's known limits.\n\n**Scope notes.** I also checked the focus areas the brief names and found no further defect: attestation replay is blocked by the monotonic accepted id, chain id, verifying contract and the answer binding to the pending id; an ability cannot block trades because it runs under STATICCALL with a gas cap and falls back to 1x; the treasury split is exact at 40% of a 2.5% fee; claims are pull-only behind a reentrancy guard and a locked-manager check. Slither did not run, as the task instructs.","treeHash":null,"usage":{"cachedInputTokens":2391533,"inputTokens":738,"model":"claude-fable-5-1","outputTokens":43083,"runtime":"claude","turns":34,"wallClockMs":575379}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"99e4819277ea59fc","findings":[{"citation":"resolved","description":"afterSwap credits every sell to tx.origin and _play then sets sold[round][tx.origin] = true and removes all of that wallet's damage from eligibleDamage, whatever the size of the sell and whoever's BOSS was sold. The forfeit is all-or-nothing and the identity is the transaction signer, not the account whose tokens moved. A contract that owns a little BOSS can therefore sell 1 wei of IMD worth of it (exact-output amountSpecified = 1: hook fee rounds to zero, heal is 1 wei) inside any transaction a player sends to it, and that player is recorded as a seller for the round. Their damage share (part of the 45% damage pool) is redistributed pro rata to the remaining eligible wallets, including the attacker's. The player signs nothing that mentions BOSS, the pool or a sale; any dapp interaction (a fake airdrop claim, a quest, a leaderboard contract, a compromised third-party contract, or an EIP-7702 delegated action) is enough. The README's Known limits only mention that bundler-relayed smart wallets are credited to the relayer; this punitive misattribution of a third party's sell is not covered. Fix options that keep the design: (1) stop blaming tx.origin for a sell it did not initiate, e.g. attribute sells to the router's msgSender()/the PoolManager caller (sender) with no tx.origin fallback, so an unrelated contract's sell lands on that contract; (2) make the forfeit proportional (forfeit damage in the ratio of IMD sold to IMD bought, or ignore sells below a dust fraction of the seller's own damage) so a planted 1-wei sell cannot erase a share; at minimum, document the vector in Known limits and in the UI.","line":342,"path":"src/BossHook.sol","reproduction":"State: round 1, alice bought 0.4 IMD (damage 0.4, eligible) and bob bought 0.4 IMD (damage 0.4, eligible); eligibleDamage(1) == 0.8e18. Bob deploys a contract L that holds 1000 BOSS and whose claim() calls the pool with SwapParams(zeroForOne = sell BOSS, amountSpecified = +1 (exact-output 1 wei of IMD), limit) and empty hookData. Alice (never sold) sends one transaction to L.claim(). Expected: alice's eligibility is untouched because none of her BOSS was sold and she signed no sale. Actual: afterSwap resolves trader = tx.origin = alice, sold[1][alice] becomes true, eligibleDamage(1) drops to 0.4e18 (Forfeited event names alice). After carol kills the boss, claimableDamage(1, alice) == 0 and claimDamage(1, alice) reverts SoldThisRound, while claimableDamage(1, bob) equals the entire damagePool instead of half of it. Foundry: test/scratch/ForfeitByThirdParty.t.sol fails on this code with 'alice is marked as a seller although only bob's BOSS was sold'.","severity":"medium","snippet":"        address trader = isBuy ? _resolveTrader(sender, hookData) : tx.origin;","title":"Sells are attributed to tx.origin, so any contract a player calls can forfeit that player's whole damage share"},{"citation":"resolved","description":"acceptAbility refuses any new attestation while an ability is queued, and a queued ability can sit for up to 24 hours (its activation is pushed to lastActivationAt + MIN_ABILITY_INTERVAL). IMD attestations are valid for 24 hours (validForSeconds = 86400 in docs/ORACLE-QUESTION.md) and OracleGate rejects them once expiresAt <= block.timestamp. When the swarm keeps its daily cadence (propose 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 silently 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 replace the queue head with the newer id while keeping the earlier activation time rules), or at least document that the keeper must not propose until the queued ability has activated.","line":404,"path":"src/BossHook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\n\nimport {HookFlags} from \"src/HookFlags.sol\";\nimport {BossToken} from \"src/BossToken.sol\";\nimport {BossHook} from \"src/BossHook.sol\";\nimport {AbilityBoard} from \"src/AbilityBoard.sol\";\nimport {OracleGate} from \"src/OracleGate.sol\";\nimport {IAbility, IAbilityBoard, SwapInfo} from \"src/interfaces/IAbility.sol\";\n\ncontract QuoteToken {\n    uint8 public constant decimals = 18;\n}\n\ncontract Ability is IAbility {\n    function modifiers(SwapInfo calldata) external pure returns (uint256, uint256) {\n        return (20_000, 10_000);\n    }\n\n    function name() external pure returns (string memory) {\n        return \"x\";\n    }\n}\n\ncontract AttestationExpiresWhileQueuedTest is Test {\n    uint160 internal constant HOOK_FLAGS = HookFlags.BEFORE_INITIALIZE | HookFlags.BEFORE_SWAP | HookFlags.AFTER_SWAP\n        | HookFlags.BEFORE_SWAP_RETURN_DELTA | HookFlags.AFTER_SWAP_RETURN_DELTA;\n    bytes32 internal constant QUESTION = keccak256(\"q\");\n    uint256 internal oracleKey = 0x0DAC1E;\n\n    PoolManager internal manager;\n    BossToken internal boss;\n    QuoteToken internal imd;\n    BossHook internal hook;\n    AbilityBoard internal board;\n    address internal keeper = makeAddr(\"keeper\");\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        manager = new PoolManager(address(this));\n        imd = new QuoteToken();\n        boss = new BossToken();\n        board = new AbilityBoard(keeper);\n        bytes memory args = abi.encode(\n            IPoolManager(address(manager)),\n            address(boss),\n            keeper,\n            IAbilityBoard(address(board)),\n            vm.addr(oracleKey),\n            QUESTION,\n            uint256(1 ether),\n            uint24(129_000),\n            address(imd)\n        );\n        bytes memory initCode = abi.encodePacked(type(BossHook).creationCode, args);\n        (bytes32 salt, address predicted) = HookFlags.mineSalt(address(this), initCode, HOOK_FLAGS, 300_000);\n        address addr;\n        assembly (\"memory-safe\") {\n            addr := create2(0, add(initCode, 32), mload(initCode), salt)\n        }\n        require(addr == predicted && addr != address(0), \"hook deploy\");\n        hook = BossHook(addr);\n        bool bossIsCurrency0 = address(boss) < address(imd);\n        (Currency c0, Currency c1) = bossIsCurrency0\n            ? (Currency.wrap(address(boss)), Currency.wrap(address(imd)))\n            : (Currency.wrap(address(imd)), Currency.wrap(address(boss)));\n        PoolKey memory key = PoolKey(c0, c1, 12_500, 60, IHooks(address(hook)));\n        manager.initialize(key, TickMath.getSqrtPriceAtTick(bossIsCurrency0 ? -int24(129_000) : int24(129_000)));\n    }\n\n    /// @dev A real IMD attestation: validForSeconds = 86400 (docs/ORACLE-QUESTION.md).\n    function _attest(uint256 answer) internal view returns (OracleGate.Attestation memory a, bytes memory sig) {\n        a.requestId = keccak256(abi.encode(\"req\", answer, block.timestamp));\n        a.chainId = block.chainid;\n        a.questionHash = QUESTION;\n        a.answerType = 3;\n        a.answer = abi.encode(answer);\n        a.panelJobId = keccak256(\"panel\");\n        a.panelSize = 21;\n        a.quorum = 15;\n        a.agreed = 18;\n        a.issuedAt = uint64(block.timestamp);\n        a.expiresAt = uint64(block.timestamp + 86_400);\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(oracleKey, hook.attestationDigest(a));\n        sig = abi.encodePacked(r, s, v);\n    }\n\n    function _propose() internal returns (uint256 id) {\n        address ability = address(new Ability());\n        vm.prank(keeper);\n        board.propose(ability, \"job\");\n        (id,) = board.pending();\n        vm.warp(block.timestamp + 30 minutes); // the panel answers after the proposal\n    }\n\n    /// @notice Day 1: ability #1 accepted at T, activates T+12h. Ability #2 accepted at T+12h,\n    /// pushed to T+36h by the 24h interval. The swarm's day-3 ability #3 is proposed and approved\n    /// by the panel at about T+12h+1h; the attestation is valid for 24h, until about T+37h.\n    /// It can only be relayed once #2 has activated, at T+36h; the relayer has a sliver of time,\n    /// and any later block (here T+37h, i.e. well within the panel's daily cadence) finds the\n    /// attestation expired. The approved ability can never be queued; the swarm must pay for a\n    /// new oracle answer.\n    function test_approvedAbilityCanBeRelayedWhileAnotherIsQueued() public {\n        uint256 id1 = _propose();\n        (OracleGate.Attestation memory a1, bytes memory s1) = _attest(id1 * 2 + 1);\n        hook.acceptAbility(a1, s1);\n        uint64 act1 = hook.queuedActivatesAt(); // T+12h\n\n        vm.warp(act1 - 30 minutes);\n        uint256 id2 = _propose(); // proposed, then answered at act1\n        (OracleGate.Attestation memory a2, bytes memory s2) = _attest(id2 * 2 + 1);\n        hook.acceptAbility(a2, s2); // relayed the moment #1 activates\n        uint64 act2 = hook.queuedActivatesAt();\n        assertEq(act2, act1 + 24 hours, \"second activation pushed 24h after the first\");\n\n        // The swarm's next ability is proposed right away and the panel approves it 30 minutes later.\n        uint256 id3 = _propose();\n        (OracleGate.Attestation memory a3, bytes memory s3) = _attest(id3 * 2 + 1);\n        assertLt(a3.expiresAt, act2 + 1 hours, \"the 24h attestation outlives the queue lock by only 30 minutes\");\n\n        // Expected: an approved answer for the current pending id can be relayed when it is received\n        // (its activation may be pushed by the 24h interval). Actual on this code: AbilityAlreadyQueued.\n        (bool ok,) = address(hook).call(abi.encodeCall(hook.acceptAbility, (a3, s3)));\n        if (!ok) {\n            // Show the consequence: the lock lasts until act2, a relayer coming by one hour later\n            // (within the panel's daily cadence) is refused for good, and the approval is wasted.\n            vm.warp(act2 + 1 hours);\n            assertGt(block.timestamp, a3.expiresAt);\n            vm.expectRevert(OracleGate.InvalidAttestation.selector);\n            hook.acceptAbility(a3, s3);\n            fail(\"approved ability #3 could never be queued: locked out by queued #2 until its attestation expired\");\n        }\n        assertEq(hook.acceptedId(), id3, \"third ability accepted\");\n    }\n}","reproduction":"Timeline (T = first accept). 1) Ability #1 proposed, approved, relayed at T: queuedActivatesAt = T+12h. 2) Ability #2 proposed at T+11.5h, approved and relayed at T+12h (the moment #1 activates): queuedActivatesAt = T+36h because of the 24h interval. 3) Ability #3 proposed at T+12h, approved by the panel 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 (a relayer one hour after the slot frees) acceptAbility(a3, sig3) reverts InvalidAttestation (expired). Ability #3 can never be queued although the panel approved it for the current pending id. Foundry: test/scratch/AttestationExpiresWhileQueued.t.sol fails on this code with 'approved ability #3 could never be queued: locked out by queued #2 until its attestation expired'.","severity":"low","snippet":"        if (queuedAbility != address(0)) revert AbilityAlreadyQueued();","title":"Single ability queue slot blocks relaying a fresh oracle approval for up to 24h, long enough for the attestation to expire"},{"citation":"resolved","description":"When the paired currency is the specified side (exact-input buy, exact-output sell) the fee is pre-charged in beforeSwap from params.amountSpecified, and afterSwap requires the pool to have moved exactly gross - fee (buy) or gross (sell) 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. This is not exploitable and does not touch the pot or claims; it is a usability constraint that is not listed in the README (only the error name documents it) and that aggregators or users who set a tight limit price will hit as a plain revert. Fix: document it in the README Known limits, or compute the fee in afterSwap for these shapes too (take the fee as an unspecified-side delta on the actual IMD moved) so partial fills are charged pro rata instead of refused.","line":362,"path":"src/BossHook.sol","reproduction":"State: the launched pool right after seeding (single-sided BOSS). Input: exact-input buy with SwapParams(zeroForOne = buy BOSS, amountSpecified = -10e18 IMD, sqrtPriceLimitX96 = a limit 60 ticks above the current price) via any router. Expected (v4 semantics): the swap fills up to the limit and the hook charges 2.5% of what was actually spent. Actual: the pool stops at the limit having consumed less than 9.75e18 IMD, afterSwap's equality check fails and the whole swap reverts with PartialFillNotSupported (wrapped by the PoolManager's hook-call error). The same happens for an exact-output sell whose requested IMD plus fee exceeds what the pool can deliver (line 365).","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 price-limited or liquidity-exhausting trade fails instead of filling partially"}],"hash":"2ad3a6afe8ba12f6a92e27555904c7edb0f2760e4078f529f1a7ed39c0e1c752","nodeId":"d116280b-9296-49f4-bb11-f5586938bbc3","outcome":"completed","summary":"The review is complete. Nothing outside the findings file and the scratch test directory was changed, and the existing 141-test suite still passes.\n\n**Outcome.** Three findings written to `.imd-findings.json`, two with Foundry proofs under `test/scratch/` that fail on the current code for the stated reason.\n\n- **Medium, sell attribution via tx.origin** at `src/BossHook.sol:342`. Every sell marks the transaction signer as a seller and forfeits all of their damage for the round. A contract holding a little BOSS can sell 1 wei of IMD worth of it inside any transaction a player sends to it. That player loses their whole damage share, which flows pro rata to the remaining eligible wallets, including the attacker's. The proof has Bob's lure contract wipe Alice's share and then take the entire damage pool. The README's known limits cover relayer attribution but not this punitive misattribution.\n- **Low, single ability queue slot** at `src/BossHook.sol:404`. While an ability is queued, up to 24 hours, no new attestation can be relayed. IMD attestations are valid for 24 hours, so a panel approval that arrives shortly after the previous relay has a relay window of minutes or nothing. The approval is wasted and the swarm must pay for a new answer. The proof shows ability three locked out until its attestation has expired.\n- **Info, partial fills refused** at `src/BossHook.sol:362`. Exact-input buys and exact-output sells revert on any partial fill from a price limit or exhausted liquidity. Not exploitable, not documented.\n\n**Checked and found sound.** The fee and hook-delta math for all four swap shapes in both currency orderings against the vendored v4-core. Fee custody as ERC-6909 claims, the pull-only payouts, and the pot, treasury and reserve accounting, including reentrancy through a native-ETH payout into the pool manager. Oracle replay protection through the monotonic accepted id, chain id, verifying contract and low-s signatures. Ability isolation through STATICCALL, the gas floor and the clamp, so no ability can block or drain. The launch IMD token on mainnet has 18 decimals, symbol IMD, no pause, mint or blocklist selectors, and an owner consistent with a LayerZero OFT. Known limits already acknowledged in the README, such as the Sybil evasion of the forfeit, the initialization race and locked dust, were not reported again.","treeHash":null,"usage":{"cachedInputTokens":1867774,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":61012,"runtime":"claude","turns":39,"wallClockMs":997409}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"7eb063fcf0cf5626","findings":[{"citation":"resolved","description":"Buys credit damage to any address the caller names in hookData (abi.encode(addr) or 20 packed bytes), while sells mark only tx.origin as sold. The forfeit rule 'wallets that did not sell' is therefore not enforced against the economic actor: a single EOA can buy BOSS naming a cold address it controls, sell every BOSS it holds in the same round, and the cold address keeps full eligibility for the damage half of the pot. The README accepts 'selling from a different wallet than the one that bought' as a known limit, but with hookData no second signer, transfer or extra gas is needed, so the non-seller half is in practice an unconditional second damage share for informed players, diluting the wallets that honour the rule. Smart-account users get the same effect for free: their buys are credited to the account (router msgSender or hookData) and their sells to the bundler EOA. Trust-gap seam: access (anyone may name any trader) x asymmetry (buy identity != sell identity) x economics (damage pool split). Fixing it inside the current design would mean attributing both sides to the same identity (e.g. sells also use the resolved trader, or buys ignore hookData and use the router's msgSender/tx.origin only), or dropping hookData naming; either is a design decision the requester must take, since the README documents the current attribution.","line":342,"path":"src/BossHook.sol","reproduction":"Fresh fixture (INITIAL_HP 1 IMD, BOSS/IMD pool seeded single-sided). 1) alice swaps exact-input 0.6 IMD for BOSS with hookData = abi.encode(cold). State: damage[1][cold] = 0.6e18, damage[1][alice] = 0, eligibleDamage[1] = 0.6e18. 2) alice sells her entire BOSS balance (exact-input) in round 1. Expected (rule 'wallets that did not sell'): the 0.6 IMD of damage bought by alice is forfeited, eligibleDamage[1] = 0. Actual: sold[1][alice] = true, sold[1][cold] = false, eligibleDamage[1] stays 0.6e18. 3) bob buys 1 IMD and kills. results(1).damagePool = 14650660525003513 wei, eligibleDamage = 1570468225926446301; claimableDamage(1, cold) = 5597309241845056 wei (> 0) and hook.claimDamage(1, cold) pays it to cold. Verified with a Foundry test on this tree (test/scratch/Probe.t.sol, test_forfeitBypass_singleWallet).","severity":"low","snippet":"        address trader = isBuy ? _resolveTrader(sender, hookData) : tx.origin;","title":"Asymmetric attribution lets one wallet buy, sell out and still collect the 'did not sell' share (hookData on buys vs tx.origin on sells)"},{"citation":"resolved","description":"beforeInitialize is reachable by anyone through PoolManager.initialize once the hook has code; the only degree of freedom left by the pin is the starting price, which may be anywhere within +/-300 ticks (about +/-3%) of the launch tick. The first valid initialize sets poolInitialized, binds launchTime/roundStart and cannot be undone (no owner, no re-init). If the launch is not a single atomic transaction, a front-runner initializes at the band edge: BOSS opens ~3% more expensive (or cheaper) than launch.json states, and the launcher's planned seed position [launchTick+60, maxTick] now straddles the price, so adding it requires IMD the launcher does not hold and the modifyLiquidity reverts. The README acknowledges the race and requires atomicity; this is recorded as the concrete failure mode and its input so the launch procedure can be checked against it. Tightening LAUNCH_TICK_TOLERANCE (e.g. to one tick spacing) or checking the exact sqrtPriceX96 would remove the price freedom but is a design/launch-policy choice.","line":289,"path":"src/BossHook.sol","reproduction":"Deploy BossToken, AbilityBoard and BossHook (launchTickAbs 129000, BOSS as currency0) but do not initialize yet. A stranger calls manager.initialize(key, TickMath.getSqrtPriceAtTick(-129000 + 299)). Expected (launch.json): the pool opens at tick -129000 (2,500 IMD cap) via the launcher's own initialize. Actual: 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(900,000,000 BOSS)) reverts because the range now contains the current price and needs IMD. Verified with a Foundry test on this tree (test/scratch/Probe.t.sol, test_initRace_strangerBindsHookOffPrice).","severity":"low","snippet":"        if (tick < expectedTick - LAUNCH_TICK_TOLERANCE || tick > expectedTick + LAUNCH_TICK_TOLERANCE) {","title":"Pool initialization is permissionless: a stranger can bind the hook up to 300 ticks off the launch price and strand the single-sided seed"},{"citation":"resolved","description":"The sell branch marks tx.origin as sold before computing the heal and regardless of gross, so a sell that moves 0 IMD (1 wei of BOSS, or any sell when the pool holds no IMD) forfeits all of that wallet's eligible damage. The buy side only counts damage when gross > 0 (dmg == 0 returns early), so the two branches are asymmetric: one side needs value to count, the other does not. Combined with the tx.origin attribution, any transaction a player signs that routes even 1 wei of BOSS through the pool (an aggregator probe/split, a wallet 'dust sweep', a multi-hop route that happens to sell BOSS) silently removes them from the damage pool. Players cannot be forced to sign such a transaction, so this is a footgun rather than a theft, but it is a cheap, irreversible loss of a prize share for a 0-value action. A minimal fix in the current design: skip the sold/forfeit update when gross == 0 (or when heal == 0), keeping the rule that a real sell forfeits.","line":457,"path":"src/BossHook.sol","reproduction":"Fresh fixture (INITIAL_HP 1 IMD). 1) alice buys exact-input 0.5 IMD naming herself: eligibleDamage[1] = 0.5e18. 2) alice sells exactly 1 wei of BOSS (exact-input, hookData = abi.encode(alice)). The swap succeeds and alice's IMD balance is unchanged (0 IMD out, fee 0, heal 0). Expected: a sell that moves no value should not change her standing (or at least should be rejected). Actual: sold[1][alice] == true and eligibleDamage[1] == 0; her 0.5 IMD of damage can never be claimed for round 1. Verified with a Foundry test on this tree (test/scratch/Probe.t.sol, ProbeDustTest.test_dustSell_forfeitsEverything).","severity":"low","snippet":"                sold[r][trader] = true;","title":"A zero-value dust sell forfeits a wallet's entire damage for the round"},{"citation":"resolved","description":"acceptAbility is permissionless and the activation time is block.timestamp + 12 h at relay time (then pushed to lastActivationAt + 24 h if earlier). An IMD attestation is valid for 86,400 s (validForSeconds in docs/ORACLE-QUESTION.md) and nothing in the hook requires it to be relayed promptly, so the first relayer (in practice the keeper, who also decides when to propose) can place the activation of a known ability (e.g. a 4x buy multiplier) at a chosen time within roughly a 24 h window. Everyone can read the queued ability and its activation time, so this is not hidden information, but it is a timing lever held by the relayer that the 12 h public delay does not neutralise. This is a trust-assumption note (access x economics seam), not a permission bypass; if the requester wants deterministic timing, derive the activation from the attestation's issuedAt (at = issuedAt + ABILITY_DELAY, floor at lastActivationAt + 24 h) instead of from the relay block.","line":409,"path":"src/BossHook.sol","reproduction":"Keeper proposes id 1 (FixedAbility(40000, 10000)) at T0; the oracle signs answer 3 with issuedAt = T0 + 40 min, expiresAt = issuedAt + 24 h. The holder of the signature calls acceptAbility at any T in (issuedAt, expiresAt): queuedActivatesAt = T + 12 h. Two relayers holding the same attestation can make the ability land anywhere between issuedAt + 12 h and issuedAt + 36 h, e.g. 12 h before a planned large buy. Expected by the design text: 'applies 12 hours after it is accepted'; actual: 'accepted' is whenever the relayer chooses.","severity":"info","snippet":"        uint64 at = uint64(block.timestamp + ABILITY_DELAY);","title":"Whoever relays the attestation chooses the activation moment (up to the attestation's 24 h validity)"},{"citation":"resolved","description":"Permission map (all state-changing entry points): BossHook.beforeInitialize/beforeSwap/afterSwap/unlockCallback are onlyPoolManager and pool-pinned; acceptAbility is open but gated by the EIP-712 signature of the single oracleSigner (chainId, questionHash, panel 21/quorum 15, answer == id*2+1, issuedAt > proposedAt, expiry, low-s); claimKill/claimDamage/claimTreasury are open but pay fixed destinations behind nonReentrant and a 'manager must be locked' check; AbilityBoard.propose is keeper-only; BossToken has no roles. No guard gap was found: every storage slot has one writer path with a consistent guard, there are no initializers, no proxies, no delegatecall. The residual trust is: (1) the keeper selects abilities and receives the treasury; (2) one immutable oracle key approves them, so a compromised key can approve any ability and a rotated key freezes abilities forever; (3) condition (e) of the oracle question (no dependence on s.trader, no named addresses) is enforced only by the LLM panel, while the hook still passes trader to every ability, and abilities may legally key on amount, hp, pot and timestamp (e.g. '4x when amount >= hp' makes kills 4x cheaper for whoever can afford one buy). A signer compromise or a panel mistake therefore yields up to a 16x damage-efficiency edge (4x for one address, 0.25x for everyone else) over the pot for one 24 h window at a time, bounded by the clamps and the 12 h public delay. These are design choices documented in the README; recorded here with the actor and preconditions so they are weighed, not as defects.","line":7,"path":"src/interfaces/IAbility.sol","reproduction":"State: the oracle key (or 15 of 21 seats) signs answer id*2+1 for an ability whose modifiers() returns (40000, 10000) when s.trader == K and (2500, 10000) otherwise. After the 12 h delay, K's exact-input buy of 125 IMD deals 500 IMD of damage (kills a fresh 500 IMD boss) while any other wallet needs 2,000 IMD of buys for the same damage; K takes the killer half plus the whole damage half of the pot. Expected: condition (e) rejects this ability; actual: nothing in BossHook or OracleGate can tell, so the outcome rests on the panel/signer.","severity":"info","snippet":"    address trader;","title":"Trust assumptions: keeper, single immutable oracle signer and a panel-judged condition are the only guards on abilities that see trader, amount, hp and pot"},{"citation":"resolved","description":"Damage is capped at the remaining HP and the kill credit goes to whichever buy reaches zero first, so a buyer whose pending transaction would kill the boss can be front-run by anyone buying hp (at 1x) wei of IMD worth of BOSS; the victim's buy then lands on the new, 1.5x-tougher boss and earns no kill credit. The killer half (45% of the pot) is thus worth exactly the gas-auction price to builders/searchers, and large honest buys should go through private order flow. The README lists 'bots race for the killing blow' as a known limit; recorded here because the asymmetry (prize independent of the size of the blow, damage cap at current hp) is what makes the race cheap. A design-level mitigation would be to pay the killer proportionally to the damage of the killing buy or to add a short commit window; both change the agreed pot split and are the requester's call.","line":454,"path":"src/BossHook.sol","reproduction":"hp = 0.4 IMD, pot = 100 IMD. alice broadcasts an exact-input buy of 2 IMD (would kill, damage clamped to 0.4). bob sees it and submits an exact-input buy of 0.4 IMD with higher priority. Actual: bob's buy sets hp to 0, _kill(bob) reserves 45 IMD for bob; alice's buy then hits the new boss (hp 0.6 IMD) for 0.6 IMD and gets no kill credit, with results(1).killer == bob. Matches test_killingBlow_capsDamageAndOpensNextRound in the repository, run with the two buys in the opposite order.","severity":"info","snippet":"            if (current == dmg) _kill(trader);","title":"The killing blow is a public-mempool race: the last unit of HP can be sniped with a buy of exactly the remaining HP"}],"hash":"37a945217762469c0feff13a2ddda63263ae01f70c4ada1a13ba0d8a175efde3","nodeId":"03ca3ea3-b24d-4a9c-90ea-a6d36acd3536","outcome":"completed","summary":"The review is complete and the findings file is written and validated.\n\n**Outcome.** No critical or high defect was found. The permission model is tight: every state-changing entry point has a consistent guard, there are no initializers, proxies, delegatecalls or admin setters, the oracle attestation is bound to chain, hook, question, panel, quorum, expiry and the pending proposal id, and no attestation can be replayed. The pot can only leave through kills into pull-based claims that pay fixed destinations, and the ERC-6909 accounting invariant holds across all four swap shapes.\n\n**Findings written to `.imd-findings.json`** (six, ranked):\n\n- **Low, forfeit bypass by a single wallet.** Buys credit whoever `hookData` names, sells mark only the signer, so one EOA buys naming a cold address, sells everything, and the cold address still claims the \"did not sell\" share. Reproduced with concrete amounts in a scratch test. The README accepts cross-wallet evasion, but this variant costs nothing and needs no second signer.\n- **Low, initialization race.** Anyone can initialize the pinned pool up to 300 ticks off the launch price and bind the hook; the launcher's own initialize then reverts and the planned single-sided seed needs IMD it does not hold. Reproduced. The README already requires an atomic launch.\n- **Low, zero-value dust sell forfeits everything.** A 1-wei BOSS sell that returns 0 IMD still marks the signer as sold and wipes their eligible damage. Reproduced. A footgun rather than a theft, with a one-line mitigation.\n- **Info, relayer picks activation time.** The 12-hour delay counts from relay, and the attestation is valid for 24 hours, so the first relayer chooses when a known ability lands.\n- **Info, trust assumptions.** Keeper, a single immutable oracle key, and a panel-judged condition (e) are the only guards on abilities that still receive the trader address; a signer or panel failure yields up to a 16x damage-efficiency edge for one window.\n- **Info, kill sniping.** The killer half is a mempool race because damage is capped at remaining HP; already a documented limit.\n\n**Coverage and limits.** All of `src/` was read, the full suite was run (140 tests pass), and the v4-core delta accounting was checked against the hook's fee math. No proof files were attached since nothing reached high severity. The IMD token's on-chain behaviour could not be verified offline, so the launch still relies on IMD being a plain 18-decimal ERC-20 as the README states. Scratch tests live in `test/scratch/Probe.t.sol` and are disposable.","treeHash":null,"usage":{"cachedInputTokens":1478818,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":61158,"runtime":"claude","turns":37,"wallClockMs":919060}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"229c8cb0d9067fbe","findings":[{"citation":"resolved","description":"_play treats every non-buy swap as a sell by tx.origin and, before looking at gross, sets sold[round][tx.origin] and subtracts that wallet's entire damage[round] from eligibleDamage[round]. The buy branch early-returns on dmg == 0; the sell branch has no symmetric guard. A sell of 1 wei of BOSS moves 0 IMD (the pool's 1.25% fee rounds the input to nothing, output 0, hook fee 0, heal 0, no claim minted), yet it forfeits. Because the attribution key is tx.origin and not the wallet that signed the swap, the sell need not come from the victim: any contract the victim calls for any reason (a fake airdrop, a 'claim prize' helper, an unrelated dapp) can hold 1 wei of BOSS and run the swap inside the victim's transaction. The forfeited damage leaves eligibleDamage, so the remaining eligible wallets, the attacker among them, split that round's 45% damage pool without the victim. Expected: a trade that healed nothing and paid nothing leaves the wallet's round untouched. Actual: sold[r][victim] = true, Forfeited(r, victim, allDamage), claimableDamage(r, victim) = 0 forever. The README lists tx.origin attribution and bundler misattribution as known limits, but not that a third party can trigger the forfeit at zero cost. Fix within the current rules: do not enter the sell branch when the trade moved no value (if (gross == 0) return; or check heal == 0 before touching sold), which removes the free trigger. Closing the phishing vector itself needs a rule change for the requester to decide: bound the forfeit by what was actually sold (lost = min(damage[r][trader], gross), marking sold only once the stake is gone), or attribute sells like buys and accept the gifting trade-off the comment at line 341 rejects.","line":456,"path":"src/BossHook.sol","reproduction":"Fixture: PoolManager, BOSS/IMD pool at |tick| 129000, LP fee 12500, spacing 60, initialHp 1 IMD, single-sided BOSS liquidity (as the proof's setUp). (1) alice buys 0.3 IMD exact-in, bob buys 0.3 IMD exact-in: damage(1,alice) = 0.3e18, eligibleDamage(1) = 0.6e18, hp = 0.4e18. (2) bob deploys a contract that holds 1 wei of BOSS and whose function calls swapRouter.swap(key, SwapParams(zeroForOne = bossIsCurrency0, amountSpecified = -1, limit = extreme), settings, ''); alice calls that function (vm.prank(alice, alice) so tx.origin = alice). Observed: swap delta (-1 BOSS, 0 IMD), hp unchanged, hook ERC-6909 claims unchanged (no fee). Expected: eligibleDamage(1) still 0.6e18 and, after bob kills with a 0.4 IMD buy, claimableDamage(1, alice) == damagePool * 3 / 10 (eligible damage 1.0 IMD: 0.3 alice, 0.3 + 0.4 bob). Actual: sold[1][alice] == true, eligibleDamage(1) == 0.3e18, Forfeited(1, alice, 0.3e18) emitted, and after the kill claimableDamage(1, alice) == 0 while bob's share is the whole damage pool. Run: forge test --match-path test/scratch/ZeroGrossSellForfeit.t.sol (fails on this code at the eligibleDamage assertion).","severity":"medium","snippet":"            if (!sold[r][trader]) {\n                sold[r][trader] = true;\n                uint256 lost = damage[r][trader];","title":"A sell that moves no value still forfeits the signer's whole round, and any contract tx.origin calls can trigger it"},{"citation":"resolved","description":"All four fee formulas multiply by FEE_PIPS = 25_000 and then divide, rounding down, so a trade whose gross is below 40 wei (exact-input buy, exact-input sell) or whose pool-side amount is below 39 wei (exact-output buy poolIn, exact-output sell specified, both divided by 975_000) pays no hook fee while still counting in full as damage or heal. The treasury split rounds the same way (fee 41 wei: treasury 16, pot 25) in the pot's favour. Measured on the fixture: an exact-input buy of 39 wei IMD mints no claim (hook claims 0, pot 0, treasuryOwed 0) and credits damage(1, trader) = 39; 40 wei mints 1 wei. This is immaterial with an 18-decimal quote: killing a 500 IMD boss fee-free would take about 1.3e19 swaps and each wei of damage costs a full swap's gas, and nothing compounds. Reported because the assigned precision checklist asks for the zero-rounding boundary; no change is needed. If a strict 'every trade pays' rule is wanted, round the fee up: (specified * FEE_PIPS + PIPS - 1) / PIPS, with the same for the 975_000 divisor.","line":382,"path":"src/BossHook.sol","reproduction":"Exact-input buy with amountSpecified = -39 (39 wei IMD) on the launch pool: beforeSwap computes 39 * 25000 / 1000000 = 0 and returns a zero delta; afterSwap fee = 0, no mint, FeeCharged(trader, true, 39, 0), Hit(1, trader, 39, 39, hp - 39). Expected under '2.5% on every trade': fee 1 wei. Actual: fee 0. With amountSpecified = -40 the fee is 1 wei. Exact-output sell with amountSpecified = 38: fee = 38 * 25000 / 975000 = 0, the seller receives 38 wei with no fee. Checked in test/scratch/FeeTruncation.t.sol (exploration only).","severity":"info","snippet":"        return isBuy ? specified * FEE_PIPS / PIPS : specified * FEE_PIPS / (PIPS - FEE_PIPS);","title":"Hook fee truncates to zero below 40 wei of gross (boundary note, no economic impact)"},{"citation":"resolved","description":"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 must reject a pool fill that did not consume (or produce) the whole adjusted amount, otherwise the hook would keep a fee computed on a trade that did not happen. The rule is right and the accounting stays exact, but no test reaches either revert: every buy and sell in the suite uses the extreme sqrtPriceLimitX96, and the invariant handler swallows reverts without inspecting them. The consequence for integrators is untested and undocumented: a router or user that sets sqrtPriceLimitX96 as slippage protection on an exact-input buy (or exact-output sell) 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.","line":362,"path":"src/BossHook.sol","reproduction":"Launch fixture (initialHp 1 IMD). Exact-input buy of 50 IMD 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 with PartialFillNotSupported (selector 0x7d617bb3) wrapped by the PoolManager. Confirmed in test/scratch/PartialFill.t.sol.","severity":"info","snippet":"                if (uint256(uint128(-quoteDelta)) != gross - fee) revert PartialFillNotSupported();","title":"PartialFillNotSupported is never exercised by the suite; an exact-input buy with a slippage price limit reverts instead of filling partially"}],"hash":"71d4c9556e8a877703cec94638c17510c69151c715143c245ae8ff1433a440b9","nodeId":"39fe3bd7-244e-4588-a428-1e7c5bc7b1fe","outcome":"completed","summary":"The review is complete. The findings file holds three verified findings, the proof is embedded and double-checked, and the source tree is untouched.\n\n**Scope covered.** I read the whole tree and traced every value-moving path in BossHook, BossMath, AbilityBoard and OracleGate against the vendored v4-core hook-delta semantics: the four fee shapes, the pot/treasury split, damage and heal scaling under the ability clamp, the kill split, next-boss HP, claim shares, the gas floor, and the attestation gate. The existing 141 tests pass.\n\n**Findings written to `.imd-findings.json`:**\n\n- **Medium, `src/BossHook.sol:456`.** A sell that moves no value still forfeits the signer's whole round. The buy branch returns early on zero damage, but the sell branch marks `sold` and strips the wallet's eligible damage before looking at `gross`. Selling 1 wei of BOSS outputs 0 IMD, pays no fee and heals nothing, yet forfeits everything. Because sells are keyed on `tx.origin`, any contract the victim calls can do it inside the victim's transaction, and the forfeited share flows to the remaining eligible wallets. The Foundry proof under `test/scratch/ZeroGrossSellForfeit.t.sol` fails on the current code and passed against a patched copy with a zero-value guard. A fuller fix (bounding the forfeit by what was sold) changes the game's rules, so I flagged that as the requester's call.\n- **Info, `src/BossHook.sol:382`.** The 2.5% fee truncates to zero below 40 wei of gross (39 wei on the 975,000 divisors) while damage still counts in full. Measured: a 39 wei buy pays nothing and deals 39 wei. Immaterial at 18 decimals, reported because the assigned precision checklist asks for the zero-rounding boundary.\n- **Info, `src/BossHook.sol:362`.** `PartialFillNotSupported` is correct but never exercised by the suite. An exact-input buy with a slippage price limit reverts rather than partially filling, confirmed with a 50 IMD buy limited 120 ticks past spot. Worth a test in both orderings and a line in the known limits.\n\n**Checked and clean.** Fee rounding on all four shapes matches 2.5% of gross with the delta plumbing correct in both currency orderings. Pot, treasury and reserved prizes always equal the hook's ERC-6909 claims. The split conserves the pot and claim shares never exceed the pool. Attestations cannot be replayed: the id is monotonic, the answer encodes it, and the digest binds chain and hook. The gas floor really delivers the ability its 50k, so an ability cannot block or starve a trade. The treasury can never exceed 40% of the fee. The documented seed-sniping limit holds up numerically and is not reported as a defect.\n\n**Limits.** No Slither or long fuzz campaign ran. The phishing precondition of the medium finding needs the victim to call an attacker contract. Scratch tests under `test/scratch/` are exploration aids only and are not kept.","treeHash":null,"usage":{"cachedInputTokens":2746346,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":94608,"runtime":"claude","turns":51,"wallClockMs":1349548}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"76f57db4177175be","findings":[{"citation":"resolved","description":"The damage pool (45% of the pot 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 required), while a sell sets sold[round][tx.origin]. A trader therefore buys under a second address they control (hookData = abi.encode(label)), sells the entire position from the signing EOA, and the label is never marked sold: it keeps its full pro-rata share of the damage pool while honest non-sellers are diluted. The bypass costs nothing (no token transfer, no extra tx) and is invisible on-chain. The NatSpec 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 of the game unenforceable. A wallet-keyed rule is always Sybil-able by transferring BOSS to a fresh wallet, but the current code removes even that cost. Fix: credit buys and sells to the same identity. Resolve the trader once per swap (router msgSender(), then the sender/router, never hookData naming a different wallet and never tx.origin) and use it for damage, sold and the kill credit. Note: the existing tests always pass abi.encode(actor) with vm.prank(actor, actor), so the two keys coincide and the gap is never exercised.","line":342,"path":"src/BossHook.sol","reproduction":"State: fresh launch, INITIAL_HP = 1 IMD. 1) alice: exact-input buy of 0.5 IMD with hookData = abi.encode(aliceLabel) where aliceLabel is any address she controls. 2) bob: exact-input buy of 0.25 IMD with hookData = abi.encode(bob). eligibleDamage(1) = 0.75. 3) alice sells her whole BOSS balance (tx.origin = alice): sold[1][alice] = true but damage[1][aliceLabel] = 0.5 stays eligible. 4) bob buys hp + 0.5 IMD and kills; results(1).damagePool = P. Expected: claimableDamage(1, aliceLabel) == 0 and claimableDamage(1, bob) == P. Actual: claimableDamage(1, aliceLabel) == P*0.5/0.75 (5606312936992506 wei in the proof) and bob gets only a third of what the rule promises him. Proof: test/scratch/SellForfeitBypass.t.sol (fails now; passes when both sides are credited to the router's msgSender()/tx.origin fallback).","severity":"medium","snippet":"        address trader = isBuy ? _resolveTrader(sender, hookData) : tx.origin;","title":"Sell-forfeit rule is void: buys are credited to a free hookData label while sells forfeit tx.origin"},{"citation":"resolved","description":"Sells are attributed to tx.origin so that nobody can forfeit someone else's share by naming them in hookData. tx.origin only closes the hookData route: any contract the victim calls for an unrelated reason (an 'airdrop', a scam token's transfer hook, a helper site contract) can perform a one-wei BOSS sell through any router, and afterSwap then sets sold[round][tx.origin] = true for the victim. _play marks the seller before computing the heal and does so even when the sell moves zero IMD (gross == 0), so the attack costs one swap's gas and one wei of BOSS. The victim's damage leaves eligibleDamage, every remaining non-seller's share of the damage pool grows, and the attacker, who holds damage in the same round, is paid the difference when the boss falls. This is the 'steal another wallet's prize' case: the victim signed no sale of their own BOSS. The precondition is a victim transaction into attacker-controlled code, which is the standard tx.origin phishing surface and is cheap to set up against the top damage dealers of a round, who are visible on-chain. Fix: never key game state on tx.origin. Attribute a sell to the account that actually traded (the router's msgSender() when available, else the calling contract), the same identity used for buys, and consider skipping the sold flag when gross == 0 so a dust sell cannot be used as a marker.","line":342,"path":"src/BossHook.sol","reproduction":"State: fresh launch, INITIAL_HP = 1 IMD. 1) alice buys 0.5 IMD, bob buys 0.25 IMD (hookData = own address); eligibleDamage(1) = 0.75. 2) bob deploys Bait holding 1000 BOSS whose claimAirdrop() sells 1 wei of BOSS (SwapParams(bossIsCurrency0, -1, limit), hookData ''). 3) alice calls bait.claimAirdrop() from her EOA. Expected: sold(1, alice) == false and eligibleDamage(1) == 0.75. Actual: sold(1, alice) == true, Forfeited(1, alice, 0.5e18) is emitted, eligibleDamage(1) == 0.25. 4) bob buys hp + 0.5 IMD and kills: claimableDamage(1, bob) == the whole damagePool instead of damagePool * damage(bob) / (damage(bob) + damage(alice)). Proof: test/scratch/BaitForfeit.t.sol (fails now; passes when sells are attributed to the router's msgSender()).","severity":"medium","snippet":"        address trader = isBuy ? _resolveTrader(sender, hookData) : tx.origin;","title":"Any contract a player calls can forfeit the player's damage share (sells attributed to tx.origin, even for a zero-value sell)"},{"citation":"resolved","description":"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. beforeInitialize accepts any sqrtPriceX96 whose tick lies within LAUNCH_TICK_TOLERANCE = 300 ticks of the launch tick (about -3%/+3% in price). If the hook is deployed in a different transaction from the initialize (launch.json only says the sequence 'must' be atomic; nothing enforces it), anyone who sees the hook's code on-chain can initialize the pool at launchTick +/- 300 first. The launcher's own initialize then reverts with PoolAlreadyInitialized in the PoolManager, and the pool opens at a price the launcher did not choose; the single-sided seed liquidity placed at launchTick + TICK_SPACING may then sit on the wrong side of the price. Not a loss of funds, and moot if the deploy and initialize are really in one transaction, but it is the one unprivileged action available between the two steps. Fix: make the deployer the only allowed initializer (check the beforeInitialize sender against the deployer/keeper), or tighten the tolerance to the spacing snap only, or guarantee the one-transaction launch in the deployment script.","line":289,"path":"src/BossHook.sol","reproduction":"State: BossHook deployed (any ordering) and the pool not yet initialized. Input from any EOA: manager.initialize(PoolKey(c0, c1, 12500, 60, hook), TickMath.getSqrtPriceAtTick(launchTick(bossIsCurrency0) + 300)). Expected (per the launch notes): only the launch sequence can open the pool at the manifest's initialPrice. Actual: beforeInitialize passes (tick == expectedTick + 300 is inside the tolerance), poolInitialized becomes true at the attacker's price, and the launcher's manager.initialize(key, initialPrice) reverts with PoolAlreadyInitialized.","severity":"low","snippet":"        if (tick < expectedTick - LAUNCH_TICK_TOLERANCE || tick > expectedTick + LAUNCH_TICK_TOLERANCE) {","title":"Pool initialization can be front-run by anyone to a start price up to 300 ticks away from the launch tick"},{"citation":"resolved","description":"There is no owner and no setter; the only privileged actors are the keeper (sole proposer on the AbilityBoard, receiver of the 1% treasury via claimTreasury) and the IMD oracle signer (immutable; an approval queues whatever the keeper proposed). Within the clamp an approved ability may return buyMult = 2500 (0.25x) and sellMult = 40000 (4x): 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 a kill needs 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 24 h after the previous one). An ability may also key its multipliers on s.amount, s.hp or s.timestamp (the oracle question only forbids trader dependence), which lets the swarm shape who can land the killing blow. These are the agreed powers, documented here as trust assumptions rather than defects; if a compromised signer or keeper is a concern, bound the asymmetry between the two multipliers (e.g. sellMult <= 4 x buyMult) or let any approved id replace a running ability without the keeper.","line":11,"path":"src/libraries/BossMath.sol","reproduction":"State: keeper proposes an ability returning (2500, 40000); oracle signs answer id*2+1; acceptAbility queues it; 12 h later it is active. Sequence: a buyer sends 100 IMD gross (hp -= 25 IMD); a holder sells 10 IMD gross (hp += 40 IMD, capped at maxHp). Expected by players: 'every buy hits, every sell heals' at comparable rates. Actual: the defender out-heals the attacker 16:1 per IMD, the boss never falls, claimKill/claimDamage pay nothing for the duration, and the pot only moves when a later ability is proposed by the keeper and approved.","severity":"info","snippet":"    uint256 internal constant MAX_MULT_BPS = 40_000;","title":"Trust assumptions: keeper and IMD oracle signer together can park the pot behind an effectively unkillable boss"},{"citation":"resolved","description":"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 forfeit defects above. There are also no contract traders (smart wallets, aggregators, baits), no zero-value sells, 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, and (d) 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'.","line":169,"path":"test/invariant/BossHandler.sol","reproduction":"Run forge test --match-path test/invariant/BossInvariant.t.sol: 4,096 calls, 0 reverts, all six invariants hold. Then add a single handler action that buys with hookData = abi.encode(address(uint160(actor) + 1)) followed by a sell from the actor: the 'closed rounds never overpay' and 'eligible damage == non-sellers' damage' checks still pass although the labelled wallet sold its whole position, because sold[] and damage[] are keyed on different addresses (see the two findings above).","severity":"info","snippet":"        router.swap(key, p, PoolSwapTest.TestSettings(false, false), abi.encode(a));","title":"Test suite never exercises the identity split: every swap passes hookData == tx.origin == msg.sender"},{"citation":"resolved","description":"claimableDamage rounds each share down (pool * part / total), so after every eligible wallet has claimed, up to (number of eligible wallets - 1) wei of the round's damagePool remains in totalReserved with no claimant. The same is true for a prize whose winner never claims. The amounts are negligible (wei-level per round) and the accounting invariant claims == pot + treasuryOwed + totalReserved still holds, so this is recorded for completeness: the pot cannot be drained, but a sliver of it can never be paid out either. If desired, fold the unclaimable remainder into the next seed when the last eligible wallet claims, or let the last claimant take the remainder.","line":568,"path":"src/BossHook.sol","reproduction":"State: round 1 with three eligible wallets holding damage 1, 1 and 1 wei and damagePool = 2 wei (any pool not divisible by the number of shares). Each claimDamage pays 2 * 1 / 3 = 0 wei and reverts NothingToClaim; totalReserved stays at 2 wei with no code path that can ever move it. Expected: the full damagePool is distributable; actual: the remainder is locked.","severity":"info","snippet":"        return BossMath.share(res.damagePool, damage[r][who], res.eligibleDamage);","title":"Rounding dust of each damage pool stays in totalReserved forever"}],"hash":"f3592ffb382c17131972ba0c62cd17cf5efdb43c93a385dc63bd53d35c046424","nodeId":"922e3ee6-e188-4e80-96f4-e684597911a4","outcome":"completed","summary":"The review is complete. The source tree is unchanged, the project's own 141 tests still pass, and the two scratch proofs fail on the current code for the stated reasons.\n\n**Outcome.** The money paths hold up: the 2.5% fee is taken correctly on all four swap shapes, the pot/treasury/reserved split conserves the hook's ERC-6909 claims, payouts are pull-only inside the hook's own unlock, and an attestation binds chain, hook, question, and the current board id, so it cannot be replayed for another ability or hook. I found no way to drain the pot, redirect a prize, block trades through an ability, or make the treasury receive more than 1%. The defects are in identity, not accounting.\n\n**Findings written to .imd-findings.json (6 total, 2 with Foundry proofs):**\n\n- **Medium, sell-forfeit rule is void.** Buys are credited to whatever address hookData names, while sells forfeit tx.origin. A player buys under a label they control, sells everything from the signing EOA, and the label keeps its full share of the damage pool. Proof in test/scratch/SellForfeitBypass.t.sol.\n- **Medium, any called contract can forfeit the caller.** Because sells key on tx.origin, a bait contract that sells one wei of BOSS marks whoever called it as sold, even when zero IMD moves. The remaining non-sellers, attacker included, absorb the victim's share. Proof in test/scratch/BaitForfeit.t.sol.\n- **Low, initialize front-run.** Anyone can initialize the pinned pool key up to 300 ticks off the launch tick if the hook deploy and initialize are not in one transaction.\n- **Info, trust assumptions.** Keeper plus oracle signer can field a 0.25x hit / 4x heal ability that stalls kills and payouts while it is in force.\n- **Info, test coverage.** Every test and the invariant handler use the same address for hookData, msg.sender and tx.origin, so the identity split is never exercised.\n- **Info, rounding dust** of each damage pool stays reserved forever.\n\nBoth proofs were also checked under a plausible fix (one identity for buys and sells via the router's msgSender with tx.origin fallback, hookData ignored): both pass, and the patch was reverted. The recommended fix for the two medium findings is the same change, which also narrows the unavoidable Sybil path to a real token transfer. Passes I did not run: Slither or other static analysis, long fuzz campaigns, and a fork test against the live IMD token.","treeHash":null,"usage":{"cachedInputTokens":1212040,"inputTokens":354,"model":"claude-fable-5-1","outputTokens":57963,"runtime":"claude","turns":27,"wallClockMs":769949}}],"verification":[]}