{"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":"161d570f-16e8-48f6-b5b8-d3d5bc7e2936","kind":"audit","nodes":[{"acceptedSubmissionHash":"a6017476373a6df42f17bcf3ec830004c2ac7448a96275173e3d80e2fa7c18f9","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":"2f803d438e217c3129509f0af916fc3c34aa6d84cd6129cb0c06313d0bb6734b","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":"430485e53da3a6b1ab0d67d137c852b1a552f7ec6c0291712003c82938fa9a23","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":"b7783d1b8ab991266a9e1e6245a14577b7943108e2b0817015c4246ef89df193","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":"bb8e48139bb24749b209a9abdfe5cb4c5d34a6e4806792409ea8fc771c0c4606","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":"Target: src/DrawdownBurnStrategy.sol (others are deployed context). Focus: (1) pot/pendingWall/accounted vs the vault's real ARB, pre-existing vault balance must never be spent; (2) v4 flash accounting in every unlockCallback path and onFeeReceived running inside another user's swap; (3) manipulation of the anchor, hourly-close ATH, tiers and buyback limits; (4) wall tick math, single-sided add/remove, crossed-wall burn, DoS of buildWall; (5) gas inside swaps vs the 400k cap.","parentJobId":null,"planHash":"a78e1072b7ab0f6c443ab885bf4d720143b9fd5c3330f7f4638d8858390ea167","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"161d570f-16e8-48f6-b5b8-d3d5bc7e2936","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":"51742","feedbackHash":"98e142875f8df0f94494260dc7f63b7910b1cb16960154308f416eb8b499fef8","nodeKey":"audit_economics","submissionHash":"a6017476373a6df42f17bcf3ec830004c2ac7448a96275173e3d80e2fa7c18f9","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51474","feedbackHash":"549c51acef19ef2a9397caae7a1e2e5265aa6b51e7460b1c20511e4d8523e2f3","nodeKey":"audit_flow","submissionHash":"2f803d438e217c3129509f0af916fc3c34aa6d84cd6129cb0c06313d0bb6734b","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51874","feedbackHash":"f3eb8ce176400dd626d401365272cffb2e0d6de636baf1ad04db58e38e7fd78c","nodeKey":"audit_judge","submissionHash":"430485e53da3a6b1ab0d67d137c852b1a552f7ec6c0291712003c82938fa9a23","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52124","feedbackHash":"a0d2bdafb0608c04034c64dd4e99b4d0423c7a78a4594b7add340f8d9ab211a4","nodeKey":"audit_math","submissionHash":"b7783d1b8ab991266a9e1e6245a14577b7943108e2b0817015c4246ef89df193","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51557","feedbackHash":"46d19aa9f54107ab28ab69b9b47e93d0953014785f41905ad9529c62d28d7797","nodeKey":"audit_permissions","submissionHash":"bb8e48139bb24749b209a9abdfe5cb4c5d34a6e4806792409ea8fc771c0c4606","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"92cd7100ef23a7ede07edc6feee567a32f324b02163752b898a4724aae3da2bf","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"0e3b71e2ffcd200b","findings":[{"citation":"resolved","description":"The ATH is meant to be the hourly close, and the contract documents that \"a wick inside an hour is never an ATH\" and that lifting it must be \"held to an hour's close, paid for every block\". Neither holds. The 'close' is lastMcap, which is written by the block's first reading, and that reading is taken inside the pump's own afterSwap (hook -> FeeVault.onFeeReceived -> _observe) at the post-swap tick, stepped by at most MAX_ANCHOR_STEP from the last anchor. Everything later in the same L1 block (block.number on Arbitrum) returns the pinned anchor (`a.block_ == block.number`), so the dump that follows in the same transaction never touches it. If that block is the last one of the hour with a reading, the next hour's first reading promotes the wick to the ATH. Anyone can also force a reading at will because FeeVault.onFeeReceived is unauthenticated and buyback()/buildWall() are permissionless. Cost per hour: the pool fees on an amount that moves the price 2% once, not a position held for an hour. Over a day that is roughly +60% ATH with the real price unchanged; it lowers every tier (more of each fee to the pot, less to the wall) and raises wallTarget() until the wall's top reaches the current price, at which point buildWall() lays the whole pendingWall as a buy wall directly under the manipulator, who can sell into it. The owner can undo a wall but cannot lower athMcap.","line":382,"path":"src/DrawdownBurnStrategy.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {IUnlockCallback} from \"v4-core/src/interfaces/callback/IUnlockCallback.sol\";\nimport {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {PoolId, PoolIdLibrary} from \"v4-core/src/types/PoolId.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\nimport {BalanceDelta} from \"v4-core/src/types/BalanceDelta.sol\";\nimport {SwapParams, ModifyLiquidityParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {StateLibrary} from \"v4-core/src/libraries/StateLibrary.sol\";\n\nimport {DrawdownBurnStrategy} from \"src/DrawdownBurnStrategy.sol\";\nimport {FeeVault} from \"src/FeeVault.sol\";\nimport {FeeHook} from \"src/FeeHook.sol\";\nimport {Token} from \"src/Token.sol\";\nimport {IFeeStrategy} from \"src/interfaces/IFeeStrategy.sol\";\n\ncontract MockARB is ERC20 {\n    constructor() ERC20(\"Arbitrum\", \"ARB\") {}\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\n/// @dev A local copy of the live wiring: real PoolManager, the FeeHook at a flag-mined address, the\n///      token, the vault and the strategy. The test contract is its own router (unlock callback).\ncontract AthWickTest is Test, IUnlockCallback {\n    using StateLibrary for IPoolManager;\n    using PoolIdLibrary for PoolKey;\n\n    PoolManager pm;\n    Token token;\n    MockARB arb;\n    FeeVault vault;\n    FeeHook hook;\n    DrawdownBurnStrategy strat;\n    PoolKey key;\n\n    uint8 constant A_SWAP = 0;\n    uint8 constant A_LIQ = 1;\n\n    function setUp() public {\n        vm.roll(100);\n        vm.warp(1_700_000_000);\n        pm = new PoolManager(address(this));\n        token = new Token(\"T\", \"T\", 1e27, address(this));\n        arb = new MockARB();\n        while (address(arb) < address(token)) arb = new MockARB();\n        arb.mint(address(this), 1e30);\n        vault = new FeeVault(address(this));\n\n        address hookAddr = address(uint160((uint256(0x4444) << 144) | 0x44)); // afterSwap + afterSwapReturnDelta\n        deployCodeTo(\"FeeHook.sol:FeeHook\", abi.encode(pm, address(vault), address(arb), 330, address(this)), hookAddr);\n        hook = FeeHook(hookAddr);\n\n        key = PoolKey({\n            currency0: Currency.wrap(address(token)),\n            currency1: Currency.wrap(address(arb)),\n            fee: 3000,\n            tickSpacing: 60,\n            hooks: IHooks(hookAddr)\n        });\n        pm.initialize(key, uint160(1 << 96)); // tick 0, price 1 ARB per token\n        token.approve(address(pm), type(uint256).max);\n        arb.approve(address(pm), type(uint256).max);\n        // Full-range liquidity.\n        liq(-887220, 887220, 1e24);\n\n        address[] memory ex = new address[](0);\n        strat = new DrawdownBurnStrategy(pm, vault, key, token, address(arb), 0, ex, 1e24, 600, address(this));\n        vault.setStrategy(IFeeStrategy(address(strat)));\n        vault.transferOwnership(address(strat));\n        // A first reading in the deploy block, so the anchor step applies from here on.\n        strat.buyback();\n        (uint64 b, int24 at,,,) = strat.anchor();\n        assertEq(b, 100);\n        assertEq(at, 0);\n    }\n\n    // ------------------------------------------------------------------ router\n    function swap(bool zeroForOne, int256 amount, int24 limitTick) public returns (BalanceDelta d) {\n        bytes memory r = pm.unlock(abi.encode(A_SWAP, zeroForOne, amount, limitTick));\n        d = abi.decode(r, (BalanceDelta));\n    }\n\n    function liq(int24 lower, int24 upper, int256 l) public {\n        pm.unlock(abi.encode(A_LIQ, lower, upper, l));\n    }\n\n    function unlockCallback(bytes calldata data) external returns (bytes memory) {\n        require(msg.sender == address(pm));\n        uint8 a = abi.decode(data, (uint8));\n        BalanceDelta d;\n        if (a == A_SWAP) {\n            (, bool z, int256 amt, int24 lim) = abi.decode(data, (uint8, bool, int256, int24));\n            d = pm.swap(\n                key, SwapParams({zeroForOne: z, amountSpecified: amt, sqrtPriceLimitX96: TickMath.getSqrtPriceAtTick(lim)}), \"\"\n            );\n        } else {\n            (, int24 lo, int24 up, int256 l) = abi.decode(data, (uint8, int24, int24, int256));\n            (d,) = pm.modifyLiquidity(key, ModifyLiquidityParams(lo, up, l, 0), \"\");\n        }\n        _settle(key.currency0, d.amount0());\n        _settle(key.currency1, d.amount1());\n        return abi.encode(d);\n    }\n\n    function _settle(Currency c, int128 amt) internal {\n        if (amt < 0) {\n            pm.sync(c);\n            IERC20(Currency.unwrap(c)).transfer(address(pm), uint256(uint128(-amt)));\n            pm.settle();\n        } else if (amt > 0) {\n            pm.take(c, address(this), uint256(uint128(amt)));\n        }\n    }\n\n    function tick() internal view returns (int24 t) {\n        (, t,,) = IPoolManager(address(pm)).getSlot0(key.toId());\n    }\n\n    // ------------------------------------------------------------------ the test\n    /// The docs: \"a wick inside an hour is never an ATH\". One transaction: pump +1000 ticks (the\n    /// buy's afterSwap anchors the block at +200), dump back to the start. The hour closes at the\n    /// starting price. The next hour's first reading must therefore leave athMcap unchanged.\n    function test_wickInsideHourLiftsAth() public {\n        uint256 ath0 = strat.athMcap();\n        int24 t0 = tick();\n        assertEq(t0, 0);\n\n        vm.roll(101);\n        vm.warp(block.timestamp + 10 minutes);\n        // Pump: buy token with ARB until tick +1000, then dump back to tick 0, atomically.\n        swap(false, -int256(1e26), 1000);\n        assertGt(tick(), 900);\n        swap(true, -int256(1e26), 0);\n        assertLe(tick(), 0);\n        // The anchor was pinned mid-pump at +200.\n        (, int24 anchorTick,,,) = strat.anchor();\n        assertEq(anchorTick, 200);\n\n        // Next hour, first reading: the \"close\" taken is the wick.\n        vm.roll(102);\n        vm.warp((block.timestamp / 1 hours + 1) * 1 hours + 1);\n        strat.buyback();\n\n        assertEq(strat.athMcap(), ath0, \"ATH lifted by an intra-transaction wick\");\n    }\n\n    /// The first reading after deployment is not stepped at all (anchor.block_ == 0 bypasses\n    /// _stepped), although the constructor already wrote a last anchor tick.\n    function test_firstReadingAfterDeployIsUnstepped() public {\n        address[] memory ex = new address[](0);\n        DrawdownBurnStrategy fresh =\n            new DrawdownBurnStrategy(pm, vault, key, token, address(arb), 0, ex, 1e24, 600, address(this));\n        vm.roll(101);\n        swap(false, -int256(1e26), 1000); // pump to +1000 in the same tx as the first reading\n        fresh.buyback(); // first reading: anchors at the raw tick\n        (, int24 at,,,) = fresh.anchor();\n        swap(true, -int256(1e26), 0);\n        assertLe(at, 200, \"first anchor moved more than MAX_ANCHOR_STEP from the deploy anchor\");\n    }\n}","reproduction":"State: pool at tick 0, strategy anchored at tick 0 in block 100 (athMcap = m0). Block 101, one transaction: buy token with 1e26 ARB until tick +1000, then sell it all back to tick <= 0. The buy's afterSwap anchors the block at tick +200 (anchor.tick == 200 while the end-of-block price is tick 0). Block 102 in the next clock hour: call buyback(). Expected per the documented rule: athMcap == m0 (the hour closed at tick 0). Actual: athMcap == m0 * 1.0202 (NewHigh emitted). Repeating it each hour compounds. See test_wickInsideHourLiftsAth in the proof.","severity":"medium","snippet":"        if (hour != a.hour && mcap > ath) ath = mcap;","title":"Hourly-close ATH is taken from a mid-transaction wick: one atomic pump-and-dump per hour lifts athMcap by the full anchor step"},{"citation":"resolved","description":"The constructor already writes a valid last anchor tick (anchor = Anchor(0, tick, ...)) but leaves block_ at 0, and _stepped returns the raw pool tick whenever block_ == 0. So the first reading after deployment, which anyone can trigger (a swap, buyback(), buildWall(), or FeeVault.onFeeReceived), ignores MAX_ANCHOR_STEP entirely: a pump in the same transaction sets the anchor, lastMcap (the running hourly close) and the block's tier at an arbitrary tick, instead of at most 200 ticks from the deploy-time tick. It is a one-shot per deployment, but the strategy is deployed before it is installed and the first reading is whoever trades first.","line":416,"path":"src/DrawdownBurnStrategy.sol","reproduction":"Deploy the strategy with the pool at tick 0. In a later block, in one transaction: buy until tick +1000, call buyback() (first reading), sell back. Expected: anchor.tick <= 200 (deploy tick + MAX_ANCHOR_STEP). Actual: anchor.tick == 1000, and lastMcap is the +1000 reading, which becomes the ATH at the next hour's first reading. See test_firstReadingAfterDeployIsUnstepped in the proof of the ATH finding. Fix: drop the block_ == 0 special case (the constructor anchor is a real last tick), or set block_ to block.number in the constructor.","severity":"low","snippet":"        if (a.block_ == 0) return tick;","title":"First reading after deployment bypasses the anchor step: block_ == 0 lets a pump set the anchor, tier and hour-close at any tick"},{"citation":"resolved","description":"The buyback that runs inside a trader's swap must fit in FeeVault.STRATEGY_GAS_LIMIT (400k). The nested swap's cost grows with every initialized tick it crosses, and crossing a tick for the first time from below writes both feeGrowthOutside slots from zero (2 x 20k) plus cold reads: about 53k per tick in this harness. The buy window is at most MAX_SLIP_TICKS (200) above min(tick, anchor), so with the pool's spacing an attacker needs only a handful of dust positions (liquidity 1000, one mint each) whose lower ticks sit in that window. The in-swap call then runs out of gas; because buyInside is wrapped in try/catch and the whole nested swap reverts, the planted ticks are never crossed and stay fresh, so the same three positions defeat every subsequent swap-triggered buyback until someone pays for buyback() on their own gas. The README's measured peak of 327k leaves 73k of headroom, i.e. two planted ticks are enough on the live pool. Impact: the pot stops being spent by trades (the mechanism the design relies on; failure is swallowed as StrategyCallFailed), buybacks degrade to a keeper-only process, and the attacker's cost is three dust mints that move with the price only when a keeper actually crosses them.","line":257,"path":"src/DrawdownBurnStrategy.sol","reproduction":"Harness: real PoolManager, FeeHook at a flag address, tickSpacing 60, lpFee 3000, full-range liquidity 1e23, 8 excluded addresses (the maximum), pot ~2.3e21 ARB, price at tick 0, anchor at 0. Baseline: a seller's swap of 1e19 tokens triggers the in-swap buyback and spends 1.0e21 ARB (direct measurement 287k gas). Then mint three positions [60,660], [120,720], [180,780] with liquidity 1000 and repeat the same seller swap. Expected: buyback spends the same ~1.0e21. Actual: totalSpent unchanged (0 spent), the vault emits StrategyCallFailed, direct measurement 446k gas (+53k per fresh tick). See test_inSwapBuybackGas_baselineVsFreshTicks.","severity":"medium","snippet":"            try this.buyInside(amount) {} catch {}","title":"Two or three dust positions planted inside the 200-tick buy window push every in-swap buyback past the vault's 400k cap, silently and persistently"},{"citation":"resolved","description":"The stated invariant is that ARB already banked in the vault is never spent and only fees arriving after the strategy is installed are split. The constructor snapshots vault.totalReceived(reserve) at deployment, which misses two kinds of pre-existing balance. (1) Any ARB the vault holds that has not been credited by sync() (vault.pending(reserve) > 0: a direct transfer, or anything a previous strategy returned without syncing). The first permissionless vault.sync(reserve), which also runs inside every _toVault and vault.withdraw, books it into totalReceived after the snapshot, so _observe treats it as a fresh fee, splits it by the tier and the buyback spends it from the vault. (2) Every fee that arrives between deployment and the owner's later setStrategy/transferOwnership: the hook syncs them into totalReceived under the previous regime (banked), yet they are above the deploy-time snapshot and are spent at install. The vault's lastBalance accounting is explicitly balance-measured for this reason and the constructor could call vault_.sync(reserve_) before reading totalReceived; the install-time gap needs the snapshot to be taken (or re-taken) when the strategy is actually installed, e.g. on first _observe while accounted is unset, or an owner function to re-base.","line":222,"path":"src/DrawdownBurnStrategy.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {IUnlockCallback} from \"v4-core/src/interfaces/callback/IUnlockCallback.sol\";\nimport {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {PoolId, PoolIdLibrary} from \"v4-core/src/types/PoolId.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\nimport {BalanceDelta} from \"v4-core/src/types/BalanceDelta.sol\";\nimport {SwapParams, ModifyLiquidityParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {StateLibrary} from \"v4-core/src/libraries/StateLibrary.sol\";\n\nimport {DrawdownBurnStrategy} from \"src/DrawdownBurnStrategy.sol\";\nimport {FeeVault} from \"src/FeeVault.sol\";\nimport {FeeHook} from \"src/FeeHook.sol\";\nimport {Token} from \"src/Token.sol\";\nimport {IFeeStrategy} from \"src/interfaces/IFeeStrategy.sol\";\n\ncontract MockARB is ERC20 {\n    constructor() ERC20(\"Arbitrum\", \"ARB\") {}\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\n/// @dev A local copy of the live wiring: real PoolManager, the FeeHook at a flag-mined address, the\n///      token, the vault and the strategy. The test contract is its own router (unlock callback).\ncontract PendingBankTest is Test, IUnlockCallback {\n    using StateLibrary for IPoolManager;\n    using PoolIdLibrary for PoolKey;\n\n    PoolManager pm;\n    Token token;\n    MockARB arb;\n    FeeVault vault;\n    FeeHook hook;\n    DrawdownBurnStrategy strat;\n    PoolKey key;\n\n    uint8 constant A_SWAP = 0;\n    uint8 constant A_LIQ = 1;\n\n    function setUp() public {\n        vm.roll(100);\n        vm.warp(1_700_000_000);\n        pm = new PoolManager(address(this));\n        token = new Token(\"T\", \"T\", 1e27, address(this));\n        arb = new MockARB();\n        while (address(arb) < address(token)) arb = new MockARB();\n        arb.mint(address(this), 1e30);\n        vault = new FeeVault(address(this));\n\n        address hookAddr = address(uint160((uint256(0x4444) << 144) | 0x44)); // afterSwap + afterSwapReturnDelta\n        deployCodeTo(\"FeeHook.sol:FeeHook\", abi.encode(pm, address(vault), address(arb), 330, address(this)), hookAddr);\n        hook = FeeHook(hookAddr);\n\n        key = PoolKey({\n            currency0: Currency.wrap(address(token)),\n            currency1: Currency.wrap(address(arb)),\n            fee: 3000,\n            tickSpacing: 60,\n            hooks: IHooks(hookAddr)\n        });\n        pm.initialize(key, uint160(1 << 96)); // tick 0, price 1 ARB per token\n        token.approve(address(pm), type(uint256).max);\n        arb.approve(address(pm), type(uint256).max);\n        // Full-range liquidity.\n        liq(-887220, 887220, 1e24);\n\n        // The bank: ARB already in the vault, delivered without a sync (vault.pending() > 0).\n        arb.transfer(address(vault), 100e18);\n        assertEq(vault.totalReceived(address(arb)), 0);\n        assertEq(vault.pending(address(arb)), 100e18);\n        address[] memory ex = new address[](0);\n        strat = new DrawdownBurnStrategy(pm, vault, key, token, address(arb), 10e27, ex, 1e24, 600, address(this));\n        vault.setStrategy(IFeeStrategy(address(strat)));\n        vault.transferOwnership(address(strat));\n        // A first reading in the deploy block, so the anchor step applies from here on.\n        strat.buyback();\n        (uint64 b, int24 at,,,) = strat.anchor();\n        assertEq(b, 100);\n        assertEq(at, 0);\n    }\n\n    // ------------------------------------------------------------------ router\n    function swap(bool zeroForOne, int256 amount, int24 limitTick) public returns (BalanceDelta d) {\n        bytes memory r = pm.unlock(abi.encode(A_SWAP, zeroForOne, amount, limitTick));\n        d = abi.decode(r, (BalanceDelta));\n    }\n\n    function liq(int24 lower, int24 upper, int256 l) public {\n        pm.unlock(abi.encode(A_LIQ, lower, upper, l));\n    }\n\n    function unlockCallback(bytes calldata data) external returns (bytes memory) {\n        require(msg.sender == address(pm));\n        uint8 a = abi.decode(data, (uint8));\n        BalanceDelta d;\n        if (a == A_SWAP) {\n            (, bool z, int256 amt, int24 lim) = abi.decode(data, (uint8, bool, int256, int24));\n            d = pm.swap(\n                key, SwapParams({zeroForOne: z, amountSpecified: amt, sqrtPriceLimitX96: TickMath.getSqrtPriceAtTick(lim)}), \"\"\n            );\n        } else {\n            (, int24 lo, int24 up, int256 l) = abi.decode(data, (uint8, int24, int24, int256));\n            (d,) = pm.modifyLiquidity(key, ModifyLiquidityParams(lo, up, l, 0), \"\");\n        }\n        _settle(key.currency0, d.amount0());\n        _settle(key.currency1, d.amount1());\n        return abi.encode(d);\n    }\n\n    function _settle(Currency c, int128 amt) internal {\n        if (amt < 0) {\n            pm.sync(c);\n            IERC20(Currency.unwrap(c)).transfer(address(pm), uint256(uint128(-amt)));\n            pm.settle();\n        } else if (amt > 0) {\n            pm.take(c, address(this), uint256(uint128(amt)));\n        }\n    }\n\n    function tick() internal view returns (int24 t) {\n        (, t,,) = IPoolManager(address(pm)).getSlot0(key.toId());\n    }\n\n    // ------------------------------------------------------------------ the test\n    function test_unsyncedBankIsSplitAndSpent() public {\n        assertEq(strat.accounted(), 0);\n        vm.roll(101);\n        vault.sync(address(arb)); // permissionless; also happens inside any _toVault / vault.withdraw\n        strat.buyback();\n        emit log_named_uint(\"pot after\", strat.pot());\n        emit log_named_uint(\"vault ARB after\", arb.balanceOf(address(vault)));\n        assertEq(arb.balanceOf(address(vault)), 100e18, \"pre-existing vault balance was spent\");\n    }\n\n    /// Fees that arrive between the strategy's deployment and its installation are banked under the\n    /// previous regime (no strategy here), yet they are split and spent once it is installed.\n    function test_feesBetweenDeployAndInstallAreSpent() public {\n        // Undo the install done in setUp: a fresh strategy, deployed but not yet installed.\n        address[] memory ex = new address[](0);\n        vm.prank(address(strat));\n        vault.transferOwnership(address(this));\n        vault.setStrategy(IFeeStrategy(address(0)));\n        vault.sync(address(arb));\n        uint256 bank0 = arb.balanceOf(address(vault));\n        DrawdownBurnStrategy fresh =\n            new DrawdownBurnStrategy(pm, vault, key, token, address(arb), 10e27, ex, 1e24, 600, address(this));\n        // Trading continues before the owner installs it: these fees are banked with no strategy.\n        vm.roll(101);\n        swap(true, -int256(1e22), -887000);\n        uint256 bank1 = arb.balanceOf(address(vault));\n        assertGt(bank1, bank0);\n        // Install.\n        vault.setStrategy(IFeeStrategy(address(fresh)));\n        vault.transferOwnership(address(fresh));\n        vm.roll(102);\n        fresh.buyback();\n        assertEq(arb.balanceOf(address(vault)), bank1, \"fees banked before installation were spent\");\n    }\n}","reproduction":"(1) Vault holds 100e18 ARB with lastBalance 0 (pending() == 100e18, totalReceived == 0). Deploy the strategy (accounted == 0), install it, then anyone calls vault.sync(ARB) and buyback(). Expected: pot() == 0 and the vault still holds 100e18. Actual: the 100e18 is split into the pot (tier 100%) and the buyback spends all of it; vault balance 0. (2) Deploy the strategy while no strategy is set; a seller swaps 1e22 tokens, the hook banks ~3.3e20 ARB; then install the strategy and call buyback(). Expected: that ARB stays banked. Actual: it is split and spent. See test_unsyncedBankIsSplitAndSpent and test_feesBetweenDeployAndInstallAreSpent.","severity":"medium","snippet":"        accounted = vault_.totalReceived(reserve_);","title":"The fee snapshot is taken at deployment, not installation, and ignores un-synced ARB: pre-existing vault balance is split and spent"}],"hash":"2f803d438e217c3129509f0af916fc3c34aa6d84cd6129cb0c06313d0bb6734b","nodeId":"e6cd9047-6841-4602-914a-251657883401","outcome":"completed","summary":"The review is complete. The findings file holds four substantiated findings, each backed by a Foundry test that fails on the current code.\n\n**What I checked**\n\nI built a local copy of the live wiring: the real v4 PoolManager, the FeeHook at a flag-mined address, the token, the vault and the strategy, with the test contract as its own router. On it I traced all five focus areas. The accounting between pot, pendingWall, accounted and the vault balance held up across buybacks, wall build, burn, move and withdraw. The flash accounting settled to zero on every unlockCallback path, and the synced-currency guard plus the reentrancy guard correctly contained the nested buyback inside another trader's swap. The wall tick math, single-sided add and remove, and the crossed-wall burn behaved as documented.\n\n**Findings, most severe first**\n\n- **Medium, ATH from a wick** at `src/DrawdownBurnStrategy.sol:382`. The hourly close is the block's first reading, which the pump's own afterSwap takes mid-transaction, and the dump that follows never touches it. One atomic pump-and-dump in the hour's last anchored block lifts athMcap by the full 2% step. Repeated hourly this lowers every tier and walks the wall target up to the current price, where buildWall lays the whole pendingWall under the manipulator.\n- **Medium, planted ticks defeat in-swap buybacks** at line 257. Each fresh initialized tick inside the 200-tick buy window costs about 53k gas on first crossing. Three dust positions pushed the in-swap buyback from 287k to 446k gas, past the 400k cap. The failed nested swap reverts, so the ticks stay fresh and the same positions block every later swap-triggered buyback.\n- **Medium, bank spent** at line 222. The fee snapshot is taken at deployment and from totalReceived only. Un-synced ARB in the vault, and every fee banked between deployment and the owner's install, is split and spent by the first buyback.\n- **Low, unstepped first reading** at line 416. The constructor writes an anchor tick but leaves block_ at zero, so the first reading after deployment ignores MAX_ANCHOR_STEP and a same-transaction pump sets the anchor, tier and hour-close anywhere.\n\n**Not reported** because the guards held: sandwiching the buyback is bounded to roughly 4% over the previous anchor, below the round-trip fees. The crossed-wall burn by a flash dump is the wall doing its job. The owner's withdrawFromVault treats the bank as lowest priority, which matches its documented semantics.\n\nThe proofs live under test/scratch and are embedded in the findings file. Nothing outside that directory and the findings file was changed.","treeHash":null,"usage":{"cachedInputTokens":1740827,"inputTokens":546,"model":"claude-fable-5-1","outputTokens":68326,"runtime":"claude","turns":23,"wallClockMs":1010881}},{"artifacts":[],"attempt":2,"bundleHash":null,"device":"fb26479062458645","findings":[{"citation":"resolved","description":"The README's invariant is that ARB already banked in the vault is never spent. `accounted` is frozen at `vault.totalReceived(reserve)` in the constructor, but FeeVault only credits totalReceived when sync() runs: reserve that reached the vault by plain transfer (bonding-curve fees, anything not routed through the hook's take+sync) sits as `vault.pending(reserve)` and is outside the snapshot. The next sync, which is permissionless and also runs inside every vault.withdraw (so inside every _payFromVault) and every _toVault, folds it into totalReceived; `_observe` then reads `total - accounted` as a fresh fee, splits it by the block's tier into pot/pendingWall, and the next in-swap buyback or buildWall spends it from the vault. Nothing forces a sync before the strategy is deployed, and anyone can trigger the sync afterwards. Merged from audit_flow, audit_math, audit_permissions and audit_economics (all reproduced). Fix: in the constructor use `accounted = vault_.totalReceived(reserve_) + vault_.pending(reserve_)` (verified: the attached proof passes with that one-line change), or call `vault_.sync(reserve_)` before reading totalReceived.","line":222,"path":"src/DrawdownBurnStrategy.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.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\";\nimport {SwapParams, ModifyLiquidityParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\nimport {Token} from \"src/Token.sol\";\nimport {FeeVault} from \"src/FeeVault.sol\";\nimport {FeeHook} from \"src/FeeHook.sol\";\nimport {DrawdownBurnStrategy} from \"src/DrawdownBurnStrategy.sol\";\n\ncontract MockARB is ERC20 {\n    constructor() ERC20(\"Arbitrum\", \"ARB\") {\n        _mint(msg.sender, 1e30);\n    }\n}\n\n/// @dev Finding: the constructor snapshots `vault.totalReceived(reserve)` without syncing the vault\n///      first, so reserve already sitting in the vault but not yet credited (balance > lastBalance)\n///      is counted as a fresh fee by the first `_observe` and then spent. The README states the\n///      pre-existing vault balance must never be spent.\ncontract BankSpentTest is Test {\n    address constant TOKEN_ADDR = address(0x1000);\n    // bits 6 (afterSwap) and 2 (afterSwapReturnDelta) set, nothing else in the low 14 bits\n    address constant HOOK_ADDR = address(uint160((uint160(0xC0FFEE) << 14) | 0x44));\n    int24 constant START_TICK = -69060; // ~0.001 ARB per token\n\n    PoolManager pm;\n    MockARB arb;\n    Token token;\n    FeeVault vault;\n    PoolSwapTest swapRouter;\n    PoolModifyLiquidityTest lpRouter;\n    PoolKey key;\n    DrawdownBurnStrategy strat;\n\n    function setUp() public {\n        pm = new PoolManager(address(this));\n        arb = new MockARB();\n        require(TOKEN_ADDR < address(arb), \"ordering\");\n        deployCodeTo(\"Token.sol:Token\", abi.encode(\"Token\", \"TKN\", uint256(1e27), address(this)), TOKEN_ADDR);\n        token = Token(TOKEN_ADDR);\n        vault = new FeeVault(address(this));\n        deployCodeTo(\n            \"FeeHook.sol:FeeHook\", abi.encode(pm, address(vault), address(arb), uint256(330), address(this)), HOOK_ADDR\n        );\n        key = PoolKey(Currency.wrap(TOKEN_ADDR), Currency.wrap(address(arb)), 3000, 60, IHooks(HOOK_ADDR));\n        pm.initialize(key, TickMath.getSqrtPriceAtTick(START_TICK));\n\n        swapRouter = new PoolSwapTest(pm);\n        lpRouter = new PoolModifyLiquidityTest(pm);\n        token.approve(address(swapRouter), type(uint256).max);\n        token.approve(address(lpRouter), type(uint256).max);\n        arb.approve(address(swapRouter), type(uint256).max);\n        arb.approve(address(lpRouter), type(uint256).max);\n        lpRouter.modifyLiquidity(key, ModifyLiquidityParams(-138120, 0, 1e21, 0), \"\");\n    }\n\n    function test_uncreditedBankIsSpent() public {\n        // The bank: 1000 ARB that reached the vault by plain transfer and was never synced.\n        // (FeeVault.sync is permissionless, nothing forces it to have run before deployment.)\n        arb.transfer(address(vault), 1000e18);\n        assertEq(vault.totalReceived(address(arb)), 0);\n        assertEq(vault.pending(address(arb)), 1000e18);\n\n        address[] memory excluded;\n        strat = new DrawdownBurnStrategy(pm, vault, key, token, address(arb), 0, excluded, 1e21, 600, address(this));\n        vault.setStrategy(strat);\n        vault.transferOwnership(address(strat));\n\n        // Any later sync credits the bank as if it were a new fee; the next notification splits it.\n        vault.sync(address(arb));\n        vault.onFeeReceived(address(arb));\n\n        // At the ATH the tier is 0%, so the whole \"fee\" is queued for the wall and laid by anyone.\n        strat.buildWall();\n\n        // Expected: the pre-existing 1000 ARB stays in the vault. Actual: it is spent on the wall.\n        assertEq(arb.balanceOf(address(vault)), 1000e18, \"pre-existing vault balance was spent\");\n    }\n}","reproduction":"State: vault.totalReceived[ARB] == lastBalance[ARB] == 0 and 1000 ARB transferred straight to the vault (vault.pending(ARB) == 1000e18). Deploy DrawdownBurnStrategy (accounted == 0), set it as the vault's strategy and owner. Anyone calls vault.sync(ARB), then vault.onFeeReceived(ARB): Split is emitted and pot()+pendingWall() == 1000e18 (tier 0% at the ATH puts it all in pendingWall). Call buildWall(): the vault's ARB balance goes from 1000e18 to 0. Expected: pot()+pendingWall() == 0 and the 1000 ARB stays in the vault. Actual: the whole pre-existing balance is laid as the wall (or, with a tier above 0%, bought back inside the next swap). Reproduced with test/scratch/Proof_71eb1b9b646e.t.sol (fails: 'pre-existing vault balance was spent: 0 != 1000000000000000000000') and independently with the audit_flow proof (vault balance 100e18 -> 0 via buyback()).","severity":"high","snippet":"        accounted = vault_.totalReceived(reserve_);","title":"Constructor baselines `accounted` on an unsynced vault: reserve already in the vault but not yet credited by sync() is split and spent as a fee"},{"citation":"resolved","description":"The header promises that 'a wick inside an hour is never an ATH' and that holding a pump to an hour's close is 'paid for every block -- a round trip through the pool's fee each time'. Neither holds. (a) The hour's 'close' is lastMcap, written by the block's first reading, and that reading is taken inside the pump's own afterSwap (hook -> FeeVault.onFeeReceived -> _observe) at the post-swap tick, stepped at most MAX_ANCHOR_STEP from the last anchor. Every later call in the same block.number returns the pinned anchor, so dumping back in the same transaction never touches it; if that block is the last with a reading in the clock hour, the next hour's first reading promotes the wick. (b) A reading needs no trade: FeeVault.onFeeReceived(address) has no access control and the strategy only checks msg.sender == vault, and buyback()/buildWall() are permissionless, so after one pump an attacker advances the anchor by 200 ticks per block.number with a plain call (no fee) until it equals the held price; on Arbitrum One block.number is the L1 block (~12 s), so a 2x takes ~35 calls / 7 minutes. The permissionless vault entry also bypasses buyback()'s PoolLocked guard: a contract calling vault.onFeeReceived from its own unlockCallback runs the strategy's observe/burn/buyInside (sync, vault.withdraw, settle) inside its own unlock, between its own swaps (reproduced: the bought tokens appear in the strategy and totalSpent rises inside a foreign unlock). athMcap only rises (lines 398-401) and has no owner reset. Consequences: every later tier is read against the inflated ATH (100% of fees to the pot, nothing to the wall) and wallTarget() sits at 40% of the inflated ATH, which can be above the real price so buildWall() returns at `tick < upper` and pendingWall is never laid, or right at the market so the whole pendingWall is laid as a buy wall under the manipulator. Merged from audit_flow (036c), audit_math (3c98), audit_permissions (8895) and audit_economics (0dec, ed35). Fix (a design decision): take the hour's close from a reading confirmed in a later block (e.g. the minimum of the hour's last reading and the next hour's first reading, or require the candidate to persist across N block-separated readings), rate-limit anchor steps by elapsed blocks since the last large move rather than per call, consider having the vault pass msg.sender so the strategy only reads from the hook's afterSwap, and add an owner-only correction for athMcap. At minimum correct the NatSpec cost claim.","line":382,"path":"src/DrawdownBurnStrategy.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {IUnlockCallback} from \"v4-core/src/interfaces/callback/IUnlockCallback.sol\";\nimport {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {PoolId, PoolIdLibrary} from \"v4-core/src/types/PoolId.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\nimport {BalanceDelta} from \"v4-core/src/types/BalanceDelta.sol\";\nimport {SwapParams, ModifyLiquidityParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {StateLibrary} from \"v4-core/src/libraries/StateLibrary.sol\";\n\nimport {DrawdownBurnStrategy} from \"src/DrawdownBurnStrategy.sol\";\nimport {FeeVault} from \"src/FeeVault.sol\";\nimport {FeeHook} from \"src/FeeHook.sol\";\nimport {Token} from \"src/Token.sol\";\nimport {IFeeStrategy} from \"src/interfaces/IFeeStrategy.sol\";\n\ncontract MockARB is ERC20 {\n    constructor() ERC20(\"Arbitrum\", \"ARB\") {}\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\n/// @dev A local copy of the live wiring: real PoolManager, the FeeHook at a flag-mined address, the\n///      token, the vault and the strategy. The test contract is its own router (unlock callback).\ncontract AthWickProof is Test, IUnlockCallback {\n    using StateLibrary for IPoolManager;\n    using PoolIdLibrary for PoolKey;\n\n    PoolManager pm;\n    Token token;\n    MockARB arb;\n    FeeVault vault;\n    FeeHook hook;\n    DrawdownBurnStrategy strat;\n    PoolKey key;\n\n    uint8 constant A_SWAP = 0;\n    uint8 constant A_LIQ = 1;\n\n    function setUp() public {\n        vm.roll(100);\n        vm.warp(1_700_000_000);\n        pm = new PoolManager(address(this));\n        token = new Token(\"T\", \"T\", 1e27, address(this));\n        arb = new MockARB();\n        while (address(arb) < address(token)) arb = new MockARB();\n        arb.mint(address(this), 1e30);\n        vault = new FeeVault(address(this));\n\n        address hookAddr = address(uint160((uint256(0x4444) << 144) | 0x44)); // afterSwap + afterSwapReturnDelta\n        deployCodeTo(\"FeeHook.sol:FeeHook\", abi.encode(pm, address(vault), address(arb), 330, address(this)), hookAddr);\n        hook = FeeHook(hookAddr);\n\n        key = PoolKey({\n            currency0: Currency.wrap(address(token)),\n            currency1: Currency.wrap(address(arb)),\n            fee: 3000,\n            tickSpacing: 60,\n            hooks: IHooks(hookAddr)\n        });\n        pm.initialize(key, uint160(1 << 96)); // tick 0, price 1 ARB per token\n        token.approve(address(pm), type(uint256).max);\n        arb.approve(address(pm), type(uint256).max);\n        // Full-range liquidity.\n        liq(-887220, 887220, 1e24);\n\n        address[] memory ex = new address[](0);\n        strat = new DrawdownBurnStrategy(pm, vault, key, token, address(arb), 0, ex, 1e24, 600, address(this));\n        vault.setStrategy(IFeeStrategy(address(strat)));\n        vault.transferOwnership(address(strat));\n        // A first reading in the deploy block, so the anchor step applies from here on.\n        strat.buyback();\n        (uint64 b, int24 at,,,) = strat.anchor();\n        assertEq(b, 100);\n        assertEq(at, 0);\n    }\n\n    // ------------------------------------------------------------------ router\n    function swap(bool zeroForOne, int256 amount, int24 limitTick) public returns (BalanceDelta d) {\n        bytes memory r = pm.unlock(abi.encode(A_SWAP, zeroForOne, amount, limitTick));\n        d = abi.decode(r, (BalanceDelta));\n    }\n\n    function liq(int24 lower, int24 upper, int256 l) public {\n        pm.unlock(abi.encode(A_LIQ, lower, upper, l));\n    }\n\n    function unlockCallback(bytes calldata data) external returns (bytes memory) {\n        require(msg.sender == address(pm));\n        uint8 a = abi.decode(data, (uint8));\n        BalanceDelta d;\n        if (a == A_SWAP) {\n            (, bool z, int256 amt, int24 lim) = abi.decode(data, (uint8, bool, int256, int24));\n            d = pm.swap(\n                key, SwapParams({zeroForOne: z, amountSpecified: amt, sqrtPriceLimitX96: TickMath.getSqrtPriceAtTick(lim)}), \"\"\n            );\n        } else {\n            (, int24 lo, int24 up, int256 l) = abi.decode(data, (uint8, int24, int24, int256));\n            (d,) = pm.modifyLiquidity(key, ModifyLiquidityParams(lo, up, l, 0), \"\");\n        }\n        _settle(key.currency0, d.amount0());\n        _settle(key.currency1, d.amount1());\n        return abi.encode(d);\n    }\n\n    function _settle(Currency c, int128 amt) internal {\n        if (amt < 0) {\n            pm.sync(c);\n            IERC20(Currency.unwrap(c)).transfer(address(pm), uint256(uint128(-amt)));\n            pm.settle();\n        } else if (amt > 0) {\n            pm.take(c, address(this), uint256(uint128(amt)));\n        }\n    }\n\n    function tick() internal view returns (int24 t) {\n        (, t,,) = IPoolManager(address(pm)).getSlot0(key.toId());\n    }\n\n    // ------------------------------------------------------------------ the test\n    /// The docs: \"a wick inside an hour is never an ATH\". One transaction: pump +1000 ticks (the\n    /// buy's afterSwap anchors the block at +200), dump back to the start. The hour closes at the\n    /// starting price. The next hour's first reading must therefore leave athMcap unchanged.\n    function test_wickInsideHourLiftsAth() public {\n        uint256 ath0 = strat.athMcap();\n        int24 t0 = tick();\n        assertEq(t0, 0);\n\n        vm.roll(101);\n        vm.warp(block.timestamp + 10 minutes);\n        // Pump: buy token with ARB until tick +1000, then dump back to tick 0, atomically.\n        swap(false, -int256(1e26), 1000);\n        assertGt(tick(), 900);\n        swap(true, -int256(1e26), 0);\n        assertLe(tick(), 0);\n        // The anchor was pinned mid-pump at +200.\n        (, int24 anchorTick,,,) = strat.anchor();\n        assertEq(anchorTick, 200);\n\n        // Next hour, first reading: the \"close\" taken is the wick.\n        vm.roll(102);\n        vm.warp((block.timestamp / 1 hours + 1) * 1 hours + 1);\n        strat.buyback();\n\n        assertEq(strat.athMcap(), ath0, \"ATH lifted by an intra-transaction wick\");\n    }\n}","reproduction":"State: pool at tick 0, strategy anchored at tick 0 in block 100 (athMcap = m0 = 1e27 with 1e27 supply at price 1). Block 101, one transaction: buy token with 1e26 ARB until tick +1000 (the buy's afterSwap anchors the block at +200 while the end-of-block price is tick 0), then sell it all back to tick <= 0. Block 102 in the next clock hour: call buyback() (any reading). Expected per the documented rule: athMcap == m0. Actual: athMcap == 1.0201986e27 (+2.02%, NewHigh emitted); repeating each hour compounds. Reproduced with test/scratch/ProofAthWick.t.sol (fails: 'ATH lifted by an intra-transaction wick'). Free walk: after one pump of +2000 ticks, calling FeeVault.onFeeReceived(address(0)) from any EOA in each of the next 10 L1 blocks moves anchor.tick 200, 400, ... 2000 with no swap and no fee. Guard bypass: a Runner whose unlockCallback calls vault.onFeeReceived(ARB) makes the strategy buy inside the Runner's unlock (test/scratch/Judge.t.sol test_foreignUnlockRunsBuyback: totalSpent 0 -> 3.26e20 inside the foreign unlock).","severity":"medium","snippet":"        if (hour != a.hour && mcap > ath) ath = mcap;","title":"Hourly-close ATH is taken from a mid-transaction reading, and anchoring costs no trade: a wick or a held pump plus gas-only readings lifts athMcap, which can never be lowered"},{"citation":"resolved","description":"The buyback that runs inside a trader's swap is a nested poolManager.swap under FeeVault.STRATEGY_GAS_LIMIT (400k). The nested swap pays for every initialized tick it crosses between the current tick and min(tick, anchor)+200; crossing a tick for the first time from below writes both feeGrowthOutside slots from zero plus cold reads, roughly 27k-53k per tick (here: +82k for three ticks on top of a 307k baseline with 8 excluded addresses). Anyone can initialize every tickSpacing boundary around the price with liquidityDelta 1000 (41 positions over +-1200 ticks, one transaction, never re-funded). The in-swap call then exhausts its gas inside buyInside; the strategy's own try/catch swallows the OutOfGas, so the vault does not even emit StrategyCallFailed, the fee is still split and the pot keeps growing, and because the failed nested swap reverts, the planted ticks are never crossed and stay fresh, so every later swap fails the same way until someone pays for buyback() on their own gas. With the README's measured 327k peak on the live pool, two planted ticks inside the window are enough. Impact: the on-trade automation the design relies on is lost, buybacks degrade to a keeper-only process, every trader still pays the full ~390k of gas for nothing, and the idle pot is predictable. Merged from audit_flow (f2cd), audit_math (06ff), audit_permissions (54b7) and audit_economics (2478). Fix preserving the design: inside a swap bound the nested swap's price limit so it crosses at most one initialized tick (e.g. the next spacing boundary above the current tick, found with the tick bitmap) and leave the rest to buyback(); the attached proof passes with a fix that keeps buying in-swap, and fails with one that merely skips the buy.","line":257,"path":"src/DrawdownBurnStrategy.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test, Vm} from \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {IUnlockCallback} from \"v4-core/src/interfaces/callback/IUnlockCallback.sol\";\nimport {IHooks} from \"v4-core/src/interfaces/IHooks.sol\";\nimport {PoolKey} from \"v4-core/src/types/PoolKey.sol\";\nimport {PoolId, PoolIdLibrary} from \"v4-core/src/types/PoolId.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\nimport {BalanceDelta} from \"v4-core/src/types/BalanceDelta.sol\";\nimport {SwapParams, ModifyLiquidityParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {StateLibrary} from \"v4-core/src/libraries/StateLibrary.sol\";\n\nimport {DrawdownBurnStrategy} from \"src/DrawdownBurnStrategy.sol\";\nimport {FeeVault} from \"src/FeeVault.sol\";\nimport {FeeHook} from \"src/FeeHook.sol\";\nimport {Token} from \"src/Token.sol\";\nimport {IFeeStrategy} from \"src/interfaces/IFeeStrategy.sol\";\n\ncontract MockARB is ERC20 {\n    constructor() ERC20(\"Arbitrum\", \"ARB\") {}\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\n/// @dev Calls the vault's open notification from inside its own unlock.\ncontract RunnerUnused is IUnlockCallback {\n    PoolManager immutable pm;\n    FeeVault immutable vault;\n    address immutable arb;\n\n    constructor(PoolManager pm_, FeeVault vault_, address arb_) {\n        pm = pm_;\n        vault = vault_;\n        arb = arb_;\n    }\n\n    function run() external {\n        pm.unlock(\"\");\n    }\n\n    function unlockCallback(bytes calldata) external returns (bytes memory) {\n        vault.onFeeReceived(arb);\n        return \"\";\n    }\n}\n\nabstract contract Harness is Test, IUnlockCallback {\n    using StateLibrary for IPoolManager;\n    using PoolIdLibrary for PoolKey;\n\n    PoolManager pm;\n    Token token;\n    MockARB arb;\n    FeeVault vault;\n    FeeHook hook;\n    DrawdownBurnStrategy strat;\n    PoolKey key;\n\n    uint8 constant A_SWAP = 0;\n    uint8 constant A_LIQ = 1;\n\n    function athArg() internal pure virtual returns (uint256);\n\n    function setUp() public {\n        vm.roll(100);\n        vm.warp(1_700_000_000);\n        pm = new PoolManager(address(this));\n        token = new Token(\"T\", \"T\", 1e27, address(this));\n        arb = new MockARB();\n        while (address(arb) < address(token)) arb = new MockARB();\n        arb.mint(address(this), 1e30);\n        vault = new FeeVault(address(this));\n\n        address hookAddr = address(uint160((uint256(0x4444) << 144) | 0x44)); // afterSwap + afterSwapReturnDelta\n        deployCodeTo(\"FeeHook.sol:FeeHook\", abi.encode(pm, address(vault), address(arb), 330, address(this)), hookAddr);\n        hook = FeeHook(hookAddr);\n\n        key = PoolKey({\n            currency0: Currency.wrap(address(token)),\n            currency1: Currency.wrap(address(arb)),\n            fee: 3000,\n            tickSpacing: 60,\n            hooks: IHooks(hookAddr)\n        });\n        pm.initialize(key, uint160(1 << 96)); // tick 0\n        token.approve(address(pm), type(uint256).max);\n        arb.approve(address(pm), type(uint256).max);\n        liq(-887220, 887220, 1e24);\n\n        // Eight excluded addresses, the live maximum.\n        address[] memory ex = new address[](8);\n        for (uint256 i = 0; i < 8; i++) {\n            ex[i] = address(uint160(0x1000 + i));\n            token.transfer(ex[i], 1);\n        }\n        strat = new DrawdownBurnStrategy(pm, vault, key, token, address(arb), athArg(), ex, 1e24, 600, address(this));\n        vault.setStrategy(IFeeStrategy(address(strat)));\n        vault.transferOwnership(address(strat));\n        strat.buyback(); // first reading in the deploy block\n    }\n\n    function swap(bool zeroForOne, int256 amount, int24 limitTick) public returns (BalanceDelta d) {\n        bytes memory r = pm.unlock(abi.encode(A_SWAP, zeroForOne, amount, limitTick));\n        d = abi.decode(r, (BalanceDelta));\n    }\n\n    function liq(int24 lower, int24 upper, int256 l) public {\n        pm.unlock(abi.encode(A_LIQ, lower, upper, l));\n    }\n\n    function unlockCallback(bytes calldata data) external returns (bytes memory) {\n        require(msg.sender == address(pm));\n        uint8 a = abi.decode(data, (uint8));\n        BalanceDelta d;\n        if (a == A_SWAP) {\n            (, bool z, int256 amt, int24 lim) = abi.decode(data, (uint8, bool, int256, int24));\n            d = pm.swap(\n                key, SwapParams({zeroForOne: z, amountSpecified: amt, sqrtPriceLimitX96: TickMath.getSqrtPriceAtTick(lim)}), \"\"\n            );\n        } else {\n            (, int24 lo, int24 up, int256 l) = abi.decode(data, (uint8, int24, int24, int256));\n            (d,) = pm.modifyLiquidity(key, ModifyLiquidityParams(lo, up, l, 0), \"\");\n        }\n        _settle(key.currency0, d.amount0());\n        _settle(key.currency1, d.amount1());\n        return abi.encode(d);\n    }\n\n    function _settle(Currency c, int128 amt) internal {\n        if (amt < 0) {\n            pm.sync(c);\n            IERC20(Currency.unwrap(c)).transfer(address(pm), uint256(uint128(-amt)));\n            pm.settle();\n        } else if (amt > 0) {\n            pm.take(c, address(this), uint256(uint128(amt)));\n        }\n    }\n\n    function tick() internal view returns (int24 t) {\n        (, t,,) = IPoolManager(address(pm)).getSlot0(key.toId());\n    }\n\n    function floor60(int24 t) internal pure returns (int24) {\n        int24 q = t / 60;\n        if (t < 0 && t % 60 != 0) q--;\n        return q * 60;\n    }\n\n    function sawStrategyCallFailed(Vm.Log[] memory logs) internal pure returns (bool) {\n        bytes32 sig = keccak256(\"StrategyCallFailed(address,address)\");\n        for (uint256 i = 0; i < logs.length; i++) {\n            if (logs[i].topics[0] == sig) return true;\n        }\n        return false;\n    }\n}\n\n/// @dev ATH far above the market: every fee goes to the pot, so a sell triggers an in-swap buyback.\ncontract TickGasProof is Harness {\n    function athArg() internal pure override returns (uint256) {\n        return 10e27;\n    }\n\n    /// A pot of ~1.5e21 ARB against 1e23 liquidity: one big sell, kept from buying by a wei of dust\n    /// so its fee only lands in the pot.\n    function _bigPot() internal {\n        liq(-887220, 887220, -9e23); // 1e23 left\n        vm.roll(101);\n        token.transfer(address(strat), 1);\n        swap(true, -int256(8e22), -887000);\n        assertGt(strat.pot(), 1e21, \"pot built\");\n        assertEq(strat.totalSpent(), 0);\n    }\n\n    /// Control: with no initialized tick on the buyback's path the in-swap buyback runs (307k gas here).\n    function test_control_inSwapBuybackRuns() public {\n        _bigPot();\n        vm.roll(102);\n        swap(true, -int256(1e20), -887000);\n        assertGt(strat.totalSpent(), 0, \"in-swap buyback ran\");\n    }\n\n    /// 1000-wei positions on every spacing boundary around the price: the nested swap crosses three\n    /// fresh ticks inside its 200-tick window, buyInside runs out of gas under the 400k cap, the\n    /// catch swallows it and nothing is bought, on this swap and every later one.\n    function test_plantedTicksStarveInSwapBuyback() public {\n        _bigPot();\n        vm.roll(102);\n        int24 base = floor60(tick());\n        for (int24 t = base - 1200; t <= base + 1200; t += 60) {\n            liq(t, t + 60, 1000);\n        }\n        vm.roll(103);\n        uint256 pot0 = strat.pot();\n        swap(true, -int256(1e20), -887000);\n        assertGt(strat.pot(), pot0, \"fee was split into the pot\");\n        assertGt(strat.totalSpent(), 0, \"in-swap buyback starved by planted ticks\");\n    }\n}","reproduction":"Harness: real PoolManager, FeeHook at a flag address (fee 330 bps), tickSpacing 60, lpFee 3000, 8 excluded addresses, full-range liquidity 1e23, price near tick 0, maxPerBuy 1e24, ATH far above the market so the whole fee goes to the pot. Block 101: build a pot of ~1.47e21 ARB with one 8e22-token sell (1 wei of token sent to the strategy first so that call only burns). Control, block 102: a 1e20-token sell -> strategy.onFeeReceived uses 307,728 gas, buyInside 214k, BoughtBack 5.6e20 ARB. Attack instead: block 102 mint modifyLiquidity(t, t+60, 1000) for every t = floor60(tick)-1200 .. +1200; block 103 the same 1e20-token sell. Expected: BoughtBack as in the control. Actual: strategy.onFeeReceived uses 389,407 gas, buyInside 295,901 and runs out of gas, no BoughtBack, totalSpent() == 0 while pot() grew; block 104 the same sell fails identically (389,407 gas); block 105 buyback() on a keeper's gas buys 5.6e20. Reproduced with test/scratch/ProofTickGas.t.sol (test_plantedTicksStarveInSwapBuyback fails: 'in-swap buyback starved by planted ticks: 0 <= 0'; test_control_inSwapBuybackRuns passes).","severity":"medium","snippet":"            try this.buyInside(amount) {} catch {}","title":"Dust positions on spacing boundaries push the in-swap buyback past the vault's 400k cap: buyInside runs out of gas on every swap, silently and persistently"},{"citation":"resolved","description":"The constructor writes a valid last anchor tick but leaves anchor.block_ at 0, and _stepped returns the raw pool tick whenever block_ == 0. The first reading after deployment, which anyone can trigger (a swap's afterSwap, buyback(), buildWall(), or the permissionless FeeVault.onFeeReceived), therefore ignores MAX_ANCHOR_STEP: a pump in the same transaction sets the anchor, lastMcap (the running hourly close) and the block's tier at an arbitrary tick instead of at most 200 ticks from the deploy-time tick, and if that is the hour's last reading it becomes the irreversible ATH. One-shot per deployment, but the strategy is deployed before it is installed and the first reading is whoever trades first. Distinct from the hourly-close finding: this is the unbounded single-transaction case with a one-line fix. Merged from audit_flow (4f25) and audit_math (f7ec). Fix: delete the `a.block_ == 0` early return (the constructor's tick is a real last anchor), or write block.number into anchor.block_ in the constructor; verified that removing the line makes the attached proof pass.","line":416,"path":"src/DrawdownBurnStrategy.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.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\";\nimport {SwapParams, ModifyLiquidityParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\nimport {Token} from \"src/Token.sol\";\nimport {FeeVault} from \"src/FeeVault.sol\";\nimport {FeeHook} from \"src/FeeHook.sol\";\nimport {DrawdownBurnStrategy} from \"src/DrawdownBurnStrategy.sol\";\n\ncontract MockARB is ERC20 {\n    constructor() ERC20(\"Arbitrum\", \"ARB\") {\n        _mint(msg.sender, 1e30);\n    }\n}\n\n/// @dev Finding: `_stepped` returns the raw pool tick when `anchor.block_ == 0`, which is the state\n///      the constructor leaves behind. The very first observation after deployment is therefore not\n///      limited to MAX_ANCHOR_STEP from the deploy-time tick: a single-transaction pump sets the\n///      anchor and `lastMcap` at the pumped price, and the next hour's first reading promotes that\n///      wick to the ATH. The documented guarantee is that a wick inside an hour is never an ATH and\n///      that the anchor moves at most ~2% per block.\ncontract UnsteppedFirstAnchorTest is Test {\n    address constant TOKEN_ADDR = address(0x1000);\n    address constant HOOK_ADDR = address(uint160((uint160(0xC0FFEE) << 14) | 0x44));\n    int24 constant START_TICK = -69060;\n\n    PoolManager pm;\n    MockARB arb;\n    Token token;\n    FeeVault vault;\n    PoolSwapTest swapRouter;\n    PoolModifyLiquidityTest lpRouter;\n    PoolKey key;\n    DrawdownBurnStrategy strat;\n\n    function setUp() public {\n        pm = new PoolManager(address(this));\n        arb = new MockARB();\n        require(TOKEN_ADDR < address(arb), \"ordering\");\n        deployCodeTo(\"Token.sol:Token\", abi.encode(\"Token\", \"TKN\", uint256(1e27), address(this)), TOKEN_ADDR);\n        token = Token(TOKEN_ADDR);\n        vault = new FeeVault(address(this));\n        deployCodeTo(\n            \"FeeHook.sol:FeeHook\", abi.encode(pm, address(vault), address(arb), uint256(330), address(this)), HOOK_ADDR\n        );\n        key = PoolKey(Currency.wrap(TOKEN_ADDR), Currency.wrap(address(arb)), 3000, 60, IHooks(HOOK_ADDR));\n        pm.initialize(key, TickMath.getSqrtPriceAtTick(START_TICK));\n\n        swapRouter = new PoolSwapTest(pm);\n        lpRouter = new PoolModifyLiquidityTest(pm);\n        token.approve(address(swapRouter), type(uint256).max);\n        token.approve(address(lpRouter), type(uint256).max);\n        arb.approve(address(swapRouter), type(uint256).max);\n        arb.approve(address(lpRouter), type(uint256).max);\n        lpRouter.modifyLiquidity(key, ModifyLiquidityParams(-138120, 0, 1e21, 0), \"\");\n\n        vm.warp(1_000_000 * 3600); // a clean hour boundary, well away from zero\n        vm.roll(100);\n        address[] memory excluded;\n        strat = new DrawdownBurnStrategy(pm, vault, key, token, address(arb), 0, excluded, 1e21, 600, address(this));\n        vault.setStrategy(strat);\n        vault.transferOwnership(address(strat));\n    }\n\n    function test_singleTxPumpBecomesAth() public {\n        uint256 mcap0 = strat.marketCap();\n        assertEq(strat.athMcap(), mcap0);\n\n        // Same block as deployment, one transaction: pump ~3.8x (30 ARB in against L = 1e21), which\n        // notifies the strategy from afterSwap and anchors the block at the pumped tick unstepped.\n        uint256 before = token.balanceOf(address(this));\n        swapRouter.swap(\n            key,\n            SwapParams(false, -int256(30e18), TickMath.MAX_SQRT_PRICE - 1),\n            PoolSwapTest.TestSettings(false, false),\n            \"\"\n        );\n        uint256 bought = token.balanceOf(address(this)) - before;\n        (uint64 anchoredBlock,,,,) = strat.anchor();\n        assertEq(anchoredBlock, block.number);\n        assertGt(strat.marketCap(), mcap0 * 3, \"pump should have moved the pool far past one step\");\n\n        // Dump back in the same transaction; the block's anchor is already written.\n        swapRouter.swap(\n            key,\n            SwapParams(true, -int256(bought), TickMath.MIN_SQRT_PRICE + 1),\n            PoolSwapTest.TestSettings(false, false),\n            \"\"\n        );\n\n        // Next hour: the first reading closes the previous hour at its last reading, the pumped one.\n        vm.warp(block.timestamp + 1 hours);\n        vm.roll(block.number + 1);\n        vault.onFeeReceived(address(arb)); // permissionless on the vault; forwards to the strategy\n\n        // Expected: the first anchor is stepped from the deploy tick, so the hourly close and the\n        // ATH can be at most ~2.02% (200 ticks) above the deploy market cap. Actual: ~3.8x.\n        assertLe(strat.athMcap(), mcap0 * 10202 / 10000, \"a single-tx wick became the ATH\");\n    }\n}","reproduction":"Deploy at tick -69060 (supply 1e27, L = 1e21) with athMcap_ = 0 so ath = mcap0 = 1.0e24. In a later block, one transaction: buy with 30 ARB (pool ends ~3.8x higher), whose afterSwap is the first reading and anchors the block unstepped; sell the tokens back. Warp one hour and call vault.onFeeReceived(ARB). Expected with stepping from the constructor's tick: athMcap <= mcap0 * 1.0202 (200 ticks). Actual: athMcap == 3.79e24 (3.8x). Reproduced with test/scratch/Proof_f7ec43a1920d.t.sol (fails: 'a single-tx wick became the ATH: 3790255889948495916343792 > 1022345239503356849507920'); the same in the audit_flow harness: anchor.tick == 1000 after a +1000 pump in the first-reading transaction.","severity":"low","snippet":"        if (a.block_ == 0) return tick;","title":"First reading after deployment bypasses the anchor step: block_ == 0 lets a same-transaction pump set the anchor, tier and hourly close at any tick"},{"citation":"resolved","description":"The baseline for fresh fees is fixed in the constructor, not when the strategy becomes the vault's strategy and owner. Every ARB fee the hook banks between the deploy transaction and the owner's later vault.setStrategy()/transferOwnership() (separate transactions, trading continues meanwhile) is above the snapshot, so the first _observe after installation splits it into pot/pendingWall and spends it, although by the README's definition it was banked before installation. Different root cause from the unsynced-balance finding (timing of the baseline rather than what the counter includes), so it needs a different fix: an owner-only install()/start() step that sets accounted = vault.totalReceived(reserve) when the strategy is activated, or a re-baseline inside the first _observe when the strategy first finds itself installed, or a documented requirement that deploy and install happen atomically from a contract. Reported by audit_flow (7a71, second test) and audit_permissions (86f9); both reproduced. The strategy also has no passthrough for vault.setStrategy, so if ownership is transferred to it before setStrategy the only recovery is releaseVault() (audit_permissions, info): an explicit install step would close both.","line":222,"path":"src/DrawdownBurnStrategy.sol","reproduction":"Vault synced with a 100 ARB bank, no strategy set. Deploy the strategy: accounted == 100e18. Before setStrategy, a seller swaps 1e22 tokens: the hook banks ~3.26e20 ARB into totalReceived with no strategy to notify. Then vault.setStrategy(strategy), vault.transferOwnership(strategy), next block buyback(). Expected: the vault still holds 100e18 + 3.26e20 (both banked before installation). Actual: the 3.26e20 is split and the buyback spends it; vault balance returns to 100e18. Reproduced with the audit_flow proof test_feesBetweenDeployAndInstallAreSpent (fails: 'fees banked before installation were spent: 100000000000000000000 != 425762151351030228620').","severity":"low","snippet":"        accounted = vault_.totalReceived(reserve_);","title":"Fees that arrive between deployment and installation are counted as spendable, contrary to 'spends only fees that arrive after it is installed'"},{"citation":"resolved","description":"vault.withdraw syncs first, so any reserve the vault holds uncredited (a plain transfer, or fees recorded by the vault but not yet observed because an in-swap call was cut short) is added to totalReceived during the withdrawal. The reconciliation that follows (lines 659-665) compares pot+pendingWall against the post-withdraw balance and shrinks them only to that balance; the next _observe then splits the same amount (total - accounted) on top, so pot+pendingWall exceeds the vault's reserve by exactly that amount, permanently. _build does not cap at the balance, so buildWall reverts inside vault.withdraw (ERC20InsufficientBalance) whenever pendingWall exceeds the balance, until enough new fees arrive or the owner calls withdrawFromVault(reserve, to, 0) again; _due caps at the whole vault balance including any remaining bank, so a phantom pot spends bank ARB. Merged from audit_math (0cd0), audit_permissions (3452) and audit_economics (eb64). Fix: at the top of withdrawFromVault call vault.sync(reserve) and then _observe(), so everything the vault has received is split before the balance comparison (_observe alone misses unsynced transfers because it reads totalReceived).","line":657,"path":"src/DrawdownBurnStrategy.sol","reproduction":"ATH at the market so a fee goes entirely to pendingWall. Block 101: a 1e22-token sell banks F = 3.26e20 ARB; pendingWall() == F == vault balance, pot() == 0. Transfer 50e18 ARB directly to the vault without syncing. Owner calls withdrawFromVault(ARB, owner, F + 50e18 - 1e17): vault balance is 1e17 and pendingWall is reconciled to 1e17. Block 102: vault.onFeeReceived(ARB) (any reading). Expected: pot()+pendingWall() <= 1e17 and buildWall succeeds. Actual: pot()+pendingWall() == 50.1e18 against a 1e17 balance, and buildWall() reverts. Reproduced with test/scratch/Judge.t.sol test_withdrawFromVaultLeavesCountersAboveBalance (expectRevert on buildWall satisfied; final assert fails: 'counters exceed the vault balance: 50100000000000000000 > 100000000000000000').","severity":"low","snippet":"        vault.withdraw(token_, to, amount);","title":"withdrawFromVault reconciles pot/pendingWall before the reserve its own withdraw just credited is split, leaving the counters above the vault balance and buildWall reverting"},{"citation":"resolved","description":"onFeeReceived takes the burn-only branch whenever the strategy holds any token and returns without calling _due()/buyInside. The balance is externally writable: anyone can transfer 1 wei of the token to the strategy (one checkpointed transfer, well under a cent on Arbitrum), so a griefer who keeps a wei present before each sell, or a seller who does it right before dumping, converts every in-swap buyback into a 1-wei burn; the pot accumulates and is only spent when someone pays for buyback(). Together with the planted-ticks finding this is the second cheap way to disable the on-trade automation. Merged from audit_math (0e9f), audit_permissions (df8e) and audit_economics (e706). Fix: track what the strategy's own buybacks bought in storage (set in _buy, cleared in _burnHeld) and branch on that rather than on balanceOf, burning any dust alongside the next buy; or after burning a balance below a threshold continue to the buy when gas permits.","line":250,"path":"src/DrawdownBurnStrategy.sol","reproduction":"State: ATH far above the market (100% of fees to the pot), pot 0, no tokens held. Block 101: griefer calls token.transfer(strategy, 1); a trader sells 1e22 tokens. Expected: the sell's fee is split and spent inside that swap (BoughtBack). Actual: Burned(1) is emitted, onFeeReceived returns at line 252, totalSpent() == 0, totalBurned() == 1, pot() == 3.26e20. Block 102, the same sell without the dust: BoughtBack 6.45e20 inside the swap. Reproduced with test/scratch/Judge.t.sol test_oneWeiDustSkipsInSwapBuyback.","severity":"low","snippet":"        if (token.balanceOf(address(this)) != 0) {","title":"One wei of token sent to the strategy turns the next in-swap call into a dust burn and skips the buyback that swap should have run"},{"citation":"resolved","description":"ERC20Checkpoints.sol in this repository notes that block.number on Arbitrum is the L1 block number and uses timestamps instead; the strategy keys the anchor, the block's tier and the buyback's once-per-block limit on block.number. One 'block' here therefore spans every L2 block sharing an L1 block number (typically ~12 s, longer during L1 delays): the first swap in that window fixes the tier and the buyback for every later swap in it, at most one buyback runs per ~12 s, and the free anchor walk in the ATH finding advances once per L1 block. If per-L1-block behaviour is intended, document it; if per-L2-block was intended, key on block.timestamp (as the token does) or ArbSys.arbBlockNumber(). Reported by audit_permissions (31bb); confirmed from the repository's own note and Arbitrum's documented semantics, not reproducible in the local harness.","line":392,"path":"src/DrawdownBurnStrategy.sol","reproduction":"On Arbitrum One, two swaps 3 seconds apart with the same L1 block number: the second sees anchor.block_ == block.number (line 392) and anchor.lastBuy == block.number (line 426), so it gets no new anchor, no new tier and no buyback. Expected per the NatSpec ('once a block', 'read once per block'): a new anchor and buyback in a new L2 block. Actual: shared with the earlier swap for the rest of the L1 block.","severity":"info","snippet":"        if (a.block_ != block.number) {","title":"'Once per block', the anchor step and lastBuy are keyed on block.number, which on Arbitrum One is the L1 block number (~12 s)"}],"hash":"430485e53da3a6b1ab0d67d137c852b1a552f7ec6c0291712003c82938fa9a23","nodeId":"8f86cb0f-ffa9-4c90-9b51-ee332d8aa582","outcome":"completed","summary":"The review is complete. The findings file `.imd-findings.json` holds eight findings, every one reproduced against the code with Foundry, and the source tree is unchanged.\n\n**What I kept, merged from the 23 specialist findings**\n\n- **High, line 222.** The constructor snapshots `totalReceived` without accounting for unsynced reserve, so pre-existing vault ARB is split and spent. Four specialists reported it; merged into one. Proof attached, fails now and passes with a one-line sync-aware baseline, which I verified by patching and restoring the source.\n- **Medium, line 382.** The hourly-close ATH is taken from a mid-transaction reading, and readings cost no trade because the vault's notification is permissionless. One wick per hour lifts the irreversible ATH by the full anchor step. Four specialists' findings plus the foreign-unlock guard bypass merged here. Proof attached and failing.\n- **Medium, line 257.** Dust positions on spacing boundaries push the in-swap buyback past the 400k cap. The specialists' numbers were unreproducible as stated, so I built my own harness: with a pot large enough to walk the window, three fresh ticks raise the call from 307k to 389k and buyInside runs out of gas on every swap. Proof attached, with a passing control.\n- **Low, line 416.** The first reading after deployment bypasses the anchor step. Proof attached, verified to pass once the early return is removed.\n- **Low, line 222.** Fees between deployment and installation are spent. Kept separate because the fix differs from the unsynced-balance finding.\n- **Low, line 657.** withdrawFromVault reconciles before the reserve its own withdraw credited is split, leaving counters above the balance and buildWall reverting.\n- **Low, line 250.** One wei of token sent to the strategy turns the in-swap call into a dust burn.\n- **Info, line 392.** block.number on Arbitrum is the L1 block number, which the repo's own token code acknowledges.\n\n**What I dropped or demoted.** The \"no setStrategy passthrough\" item folded into the install-window finding as a note. The audit_economics gas-headroom measurement folded into the tick-gas finding. No specialist finding failed to reproduce outright, but two of the ATH proofs carried two tests for two different findings, so I split them so each attached proof passes under its own fix.\n\n**Not covered.** Live-pool gas numbers and Arbitrum block semantics could not be checked locally. My own pass over wall tick math, single-sided add and remove, the crossed-wall burn, and flash accounting in each unlock path found nothing beyond what is reported.","treeHash":null,"usage":{"cachedInputTokens":1646980,"inputTokens":610,"model":"claude-fable-5-1","outputTokens":35345,"runtime":"claude","turns":20,"wallClockMs":518148}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"ce319efac2b76da0","findings":[{"citation":"resolved","description":"FeeVault only credits totalReceived when sync() runs; reserve that arrived by plain transfer (bonding-curve fees, donations, anything not routed through the hook's take+sync) sits in the vault as `pending` until the next sync. The constructor reads totalReceived without syncing first, so any such balance is outside `accounted`. The first hook fee after installation calls FeeVault.sync, which folds the old balance into totalReceived; `_observe()` then computes fresh = total - accounted, splits the pre-existing reserve into pot/pendingWall and the in-swap buyback spends 25%..100% of it immediately. This breaks the README's invariant that reserve already banked in the vault must never be spent, and it is not an owner action: anyone can transfer reserve to the vault before the strategy is deployed. Fix: call vault_.sync(reserve_) in the constructor before reading totalReceived (or add vault_.pending(reserve_) to accounted).","line":222,"path":"src/DrawdownBurnStrategy.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 {PoolId} from \"v4-core/src/types/PoolId.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\nimport {SwapParams, ModifyLiquidityParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {StateLibrary} from \"v4-core/src/libraries/StateLibrary.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\n\nimport {DrawdownBurnStrategy} from \"src/DrawdownBurnStrategy.sol\";\nimport {FeeVault} from \"src/FeeVault.sol\";\nimport {FeeHook} from \"src/FeeHook.sol\";\nimport {Token} from \"src/Token.sol\";\n\n/// Minimal reserve token standing in for ARB (plain ERC20, no callbacks, no fee).\ncontract ReserveToken {\n    uint256 public totalSupply;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 a) external {\n        balanceOf[to] += a;\n        totalSupply += a;\n    }\n\n    function approve(address s, uint256 a) external returns (bool) {\n        allowance[msg.sender][s] = a;\n        return true;\n    }\n\n    function transfer(address to, uint256 a) external returns (bool) {\n        balanceOf[msg.sender] -= a;\n        balanceOf[to] += a;\n        return true;\n    }\n\n    function transferFrom(address f, address to, uint256 a) external returns (bool) {\n        if (allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= a;\n        balanceOf[f] -= a;\n        balanceOf[to] += a;\n        return true;\n    }\n}\n\n/// Local deployment of the whole stack: PoolManager, Token (currency0), reserve (currency1),\n/// FeeHook at a flag-matching address, FeeVault owned by the strategy, wide LP liquidity at tick 0.\nabstract contract Harness is Test {\n    using StateLibrary for IPoolManager;\n\n    PoolManager pm;\n    Token token;\n    ReserveToken arb;\n    FeeVault vault;\n    FeeHook hook;\n    DrawdownBurnStrategy strat;\n    PoolSwapTest swapRouter;\n    PoolModifyLiquidityTest lpRouter;\n    PoolKey key;\n    PoolId id;\n\n    uint256 constant SUPPLY = 1_000_000_000 ether;\n    int24 constant SPACING = 60;\n    address owner = address(0xA11CE);\n\n    function setUp() public virtual {\n        vm.warp(1_700_000_000);\n        vm.roll(1000);\n        pm = new PoolManager(address(this));\n        token = new Token(\"T\", \"T\", SUPPLY, address(this));\n        arb = new ReserveToken();\n        while (address(arb) < address(token)) arb = new ReserveToken();\n        vault = new FeeVault(address(this));\n        address hookAddr = address(uint160(0x1234 << 20) | uint160(0x44)); // afterSwap | afterSwapReturnDelta\n        deployCodeTo(\"FeeHook.sol:FeeHook\", abi.encode(pm, address(vault), address(arb), 330, owner), hookAddr);\n        hook = FeeHook(hookAddr);\n        key = PoolKey(Currency.wrap(address(token)), Currency.wrap(address(arb)), 3000, SPACING, IHooks(hookAddr));\n        id = key.toId();\n        pm.initialize(key, TickMath.getSqrtPriceAtTick(0)); // 1 reserve per token\n\n        swapRouter = new PoolSwapTest(pm);\n        lpRouter = new PoolModifyLiquidityTest(pm);\n        arb.mint(address(this), 1e12 ether);\n        arb.approve(address(lpRouter), type(uint256).max);\n        arb.approve(address(swapRouter), type(uint256).max);\n        token.approve(address(lpRouter), type(uint256).max);\n        token.approve(address(swapRouter), type(uint256).max);\n        lpRouter.modifyLiquidity(key, ModifyLiquidityParams(-887220, 887220, 10_000_000 ether, 0), \"\");\n    }\n\n    function deployStrategy(uint256 ath, uint256 maxPerBuy) internal {\n        address[] memory ex = new address[](0);\n        strat = new DrawdownBurnStrategy(pm, vault, key, token, address(arb), ath, ex, maxPerBuy, 600, owner);\n        vault.setStrategy(strat);\n        vault.transferOwnership(address(strat));\n    }\n\n    function sell(uint256 tokensIn) internal {\n        swapRouter.swap(\n            key, SwapParams(true, -int256(tokensIn), TickMath.MIN_SQRT_PRICE + 1), PoolSwapTest.TestSettings(false, false), \"\"\n        );\n    }\n\n    function buy(uint256 arbIn) internal {\n        swapRouter.swap(\n            key, SwapParams(false, -int256(arbIn), TickMath.MAX_SQRT_PRICE - 1), PoolSwapTest.TestSettings(false, false), \"\"\n        );\n    }\n\n    function tick() internal view returns (int24 t) {\n        (, t,,) = IPoolManager(address(pm)).getSlot0(id);\n    }\n}\n\n/// Finding: the constructor reads vault.totalReceived without syncing the vault first, so reserve\n/// that was already in the vault but not yet credited by FeeVault.sync is treated as a new fee by\n/// the first _observe() and spent. Expected: the vault's pre-existing balance (150) is never spent.\ncontract ConstructorCountsUnsyncedBank is Harness {\n    function test_preexisting_unsynced_reserve_is_spent() public {\n        // bank: 100 synced, then 50 more transferred in but not yet synced (vault.pending == 50)\n        arb.transfer(address(vault), 100 ether);\n        vault.sync(address(arb));\n        arb.transfer(address(vault), 50 ether);\n        assertEq(vault.pending(address(arb)), 50 ether);\n        uint256 bank = arb.balanceOf(address(vault)); // 150\n\n        deployStrategy(0, 1_000 ether);\n\n        // one small sell: the hook banks a fee of ~0.033 reserve and syncs, then the strategy runs\n        sell(1 ether);\n        uint256 fee = vault.totalReceived(address(arb)) - bank;\n        assertLt(fee, 0.1 ether);\n\n        // everything the strategy has counted or spent must come out of the fee alone\n        assertLe(strat.pot() + strat.pendingWall() + strat.totalSpent(), fee, \"pre-existing reserve counted as fee\");\n        // and the bank must still be there\n        assertGe(arb.balanceOf(address(vault)), bank, \"pre-existing reserve was spent\");\n    }\n}","reproduction":"Vault holds 100 reserve (synced) + 50 reserve transferred without sync (vault.pending == 50). Deploy the strategy (accounted = totalReceived = 100). One sell of 1 token delivers a hook fee of ~0.033 reserve and syncs the vault. Expected: pot + pendingWall + totalSpent == 0.033 and vault balance >= 150. Actual (test/scratch/Proof1_ConstructorBank.t.sol): pot + pendingWall + totalSpent == 50.033, and the in-swap buyback spends 12.5 of the pre-existing reserve, so the vault balance drops to ~137.5.","severity":"medium","snippet":"        accounted = vault_.totalReceived(reserve_);","title":"Constructor baselines `accounted` on an unsynced vault, so pre-existing reserve is split and spent as a fee"},{"citation":"resolved","description":"The 'close' of an hour is lastMcap, the market cap read at the first fee event of the last block that had one. That reading is taken from slot0 right after the triggering swap, so a buyer who pumps >200 ticks in the final block of the hour has the anchor stepped the full MAX_ANCHOR_STEP above the previous anchor, can sell back in the same block (no re-reading until the next block number), and the next hour's first reading promotes that wick to the ATH. The natspec promise 'a wick inside an hour is never an ATH' only holds for wicks that do not sit on the hour's last reading. On Arbitrum block.number is the L1 block (~12 s), so the 'block' is a 12 s window shared by many L2 blocks, which makes being the last reading of the hour easy. Cost is one round trip through the 3.3% fee at the size needed to move 200 ticks, ~2% ATH per hour, compounding. Separately, the per-block stepping does not cost 'a round trip through the pool's fee each time' as the comment at line 46-48 claims: after one pump the permissionless FeeVault.onFeeReceived (no access control, no swap, no fee) or buyback() re-anchors every block; in the harness 40 fee-less calls walked the anchor from +199 to the pumped tick +3636, so a single pump held for an hour sets an arbitrary ATH. Consequences: tiers read from an inflated ATH (100% burn, nothing to the wall) and wallTarget() for the next wall sits at 40% of the inflated ATH, which can be above the market, so pendingWall can never be laid (buildWall returns at `tick < upper`) until the owner intervenes; or, with a 2.5x inflated ATH, right at the market, giving the pumper exit liquidity at the top. Fix options: take the hour's close from a reading that is at least one block old (e.g. close = reading of the previous block, or the minimum of the last two readings), or require the ATH candidate to persist across N consecutive readings; and document that stepping is free.","line":382,"path":"src/DrawdownBurnStrategy.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 {PoolId} from \"v4-core/src/types/PoolId.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\nimport {SwapParams, ModifyLiquidityParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {StateLibrary} from \"v4-core/src/libraries/StateLibrary.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\n\nimport {DrawdownBurnStrategy} from \"src/DrawdownBurnStrategy.sol\";\nimport {FeeVault} from \"src/FeeVault.sol\";\nimport {FeeHook} from \"src/FeeHook.sol\";\nimport {Token} from \"src/Token.sol\";\n\n/// Minimal reserve token standing in for ARB (plain ERC20, no callbacks, no fee).\ncontract ReserveToken {\n    uint256 public totalSupply;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 a) external {\n        balanceOf[to] += a;\n        totalSupply += a;\n    }\n\n    function approve(address s, uint256 a) external returns (bool) {\n        allowance[msg.sender][s] = a;\n        return true;\n    }\n\n    function transfer(address to, uint256 a) external returns (bool) {\n        balanceOf[msg.sender] -= a;\n        balanceOf[to] += a;\n        return true;\n    }\n\n    function transferFrom(address f, address to, uint256 a) external returns (bool) {\n        if (allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= a;\n        balanceOf[f] -= a;\n        balanceOf[to] += a;\n        return true;\n    }\n}\n\n/// Local deployment of the whole stack: PoolManager, Token (currency0), reserve (currency1),\n/// FeeHook at a flag-matching address, FeeVault owned by the strategy, wide LP liquidity at tick 0.\nabstract contract Harness is Test {\n    using StateLibrary for IPoolManager;\n\n    PoolManager pm;\n    Token token;\n    ReserveToken arb;\n    FeeVault vault;\n    FeeHook hook;\n    DrawdownBurnStrategy strat;\n    PoolSwapTest swapRouter;\n    PoolModifyLiquidityTest lpRouter;\n    PoolKey key;\n    PoolId id;\n\n    uint256 constant SUPPLY = 1_000_000_000 ether;\n    int24 constant SPACING = 60;\n    address owner = address(0xA11CE);\n\n    function setUp() public virtual {\n        vm.warp(1_700_000_000);\n        vm.roll(1000);\n        pm = new PoolManager(address(this));\n        token = new Token(\"T\", \"T\", SUPPLY, address(this));\n        arb = new ReserveToken();\n        while (address(arb) < address(token)) arb = new ReserveToken();\n        vault = new FeeVault(address(this));\n        address hookAddr = address(uint160(0x1234 << 20) | uint160(0x44)); // afterSwap | afterSwapReturnDelta\n        deployCodeTo(\"FeeHook.sol:FeeHook\", abi.encode(pm, address(vault), address(arb), 330, owner), hookAddr);\n        hook = FeeHook(hookAddr);\n        key = PoolKey(Currency.wrap(address(token)), Currency.wrap(address(arb)), 3000, SPACING, IHooks(hookAddr));\n        id = key.toId();\n        pm.initialize(key, TickMath.getSqrtPriceAtTick(0)); // 1 reserve per token\n\n        swapRouter = new PoolSwapTest(pm);\n        lpRouter = new PoolModifyLiquidityTest(pm);\n        arb.mint(address(this), 1e12 ether);\n        arb.approve(address(lpRouter), type(uint256).max);\n        arb.approve(address(swapRouter), type(uint256).max);\n        token.approve(address(lpRouter), type(uint256).max);\n        token.approve(address(swapRouter), type(uint256).max);\n        lpRouter.modifyLiquidity(key, ModifyLiquidityParams(-887220, 887220, 10_000_000 ether, 0), \"\");\n    }\n\n    function deployStrategy(uint256 ath, uint256 maxPerBuy) internal {\n        address[] memory ex = new address[](0);\n        strat = new DrawdownBurnStrategy(pm, vault, key, token, address(arb), ath, ex, maxPerBuy, 600, owner);\n        vault.setStrategy(strat);\n        vault.transferOwnership(address(strat));\n    }\n\n    function sell(uint256 tokensIn) internal {\n        swapRouter.swap(\n            key, SwapParams(true, -int256(tokensIn), TickMath.MIN_SQRT_PRICE + 1), PoolSwapTest.TestSettings(false, false), \"\"\n        );\n    }\n\n    function buy(uint256 arbIn) internal {\n        swapRouter.swap(\n            key, SwapParams(false, -int256(arbIn), TickMath.MAX_SQRT_PRICE - 1), PoolSwapTest.TestSettings(false, false), \"\"\n        );\n    }\n\n    function tick() internal view returns (int24 t) {\n        (, t,,) = IPoolManager(address(pm)).getSlot0(id);\n    }\n}\n\n/// Finding: the hourly close is whatever the first reading of the hour's last block saw. A pump\n/// and dump inside that single block is \"the close\", so a wick becomes the ATH, contrary to the\n/// documented rule that a wick inside an hour is never an ATH.\ncontract AthFromInBlockWick is Harness {\n    function test_wick_in_last_block_of_hour_becomes_ath() public {\n        deployStrategy(0, 1 ether);\n        uint256 ath0 = strat.athMcap();\n\n        // two ordinary readings at the resting price (tick ~0)\n        sell(1 ether);\n        vm.roll(1001);\n        vm.warp(block.timestamp + 10);\n        sell(1 ether);\n\n        // last block of the clock hour: pump past +200 ticks, then dump exactly what was bought\n        uint256 hourEnd = (block.timestamp / 1 hours + 1) * 1 hours - 1;\n        vm.warp(hourEnd);\n        vm.roll(1002);\n        uint256 before = token.balanceOf(address(this));\n        buy(300_000 ether);\n        uint256 bought = token.balanceOf(address(this)) - before;\n        sell(bought);\n        assertLt(tick(), 100, \"price is back near where it started, far under the +200 step\");\n\n        // first reading of the next hour closes the previous one\n        vm.warp(hourEnd + 2);\n        vm.roll(1003);\n        vault.onFeeReceived(address(arb));\n\n        // the wick lifted the anchor by the full 200-tick step (~2%); a real hourly close never left ~tick 0\n        assertLt(strat.athMcap(), ath0 * 101 / 100, \"an in-block wick became the hourly close and the ATH\");\n    }\n}","reproduction":"Strategy at tick 0 (ATH = 1e27 reserve). Two ordinary readings in blocks 1000 and 1001. In block 1002 at hourEnd-1s: buy 300k reserve (tick -> +589, anchor stepped to +199), then sell back the tokens bought (tick -> +20). In block 1003 at hourEnd+1s call vault.onFeeReceived (any swap would do). Expected: athMcap stays ~1e27 since the price never closed an hour above tick ~0. Actual (test/scratch/Proof2_AthWick.t.sol): athMcap == 1.0200885e27, +2.0% from a wick that existed only inside one block. Anchor walk: after one pump to +3636, 40 calls of vault.onFeeReceived in 40 successive blocks with no swap moved the anchor 199 -> 3636.","severity":"medium","snippet":"        if (hour != a.hour && mcap > ath) ath = mcap;","title":"Hourly-close ATH is set by the first reading of the hour's last block: an in-block pump/dump wick lifts the ATH, and the anchor walks to a held price for free"},{"citation":"resolved","description":"withdrawFromVault compares pot + pendingWall with the vault balance after the withdrawal, but it does not run _observe() first. Reserve that reached the vault without a hook call (direct transfer, curve fee) is synced by vault.withdraw itself, so totalReceived rises while `accounted` does not. The next _observe() then splits reserve that has already left the vault; pot + pendingWall exceeds the balance; _build() computes liquidity from pendingWall - 1 and _payFromVault(added) reverts inside vault.withdraw, so buildWall reverts for everyone until enough new fees arrive to cover the phantom amount or the owner calls withdrawFromVault(reserve, to, 0) to re-reconcile. _due() is capped by the vault balance so buybacks keep working. Fix: at the top of withdrawFromVault call vault.sync(reserve) and then _observe(), so everything the vault has received is split before the balance comparison (verified: with that ordering the proof test passes); _observe() alone is not enough because it reads totalReceived, which does not include unsynced transfers.","line":657,"path":"src/DrawdownBurnStrategy.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 {PoolId} from \"v4-core/src/types/PoolId.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\nimport {SwapParams, ModifyLiquidityParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {StateLibrary} from \"v4-core/src/libraries/StateLibrary.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\n\nimport {DrawdownBurnStrategy} from \"src/DrawdownBurnStrategy.sol\";\nimport {FeeVault} from \"src/FeeVault.sol\";\nimport {FeeHook} from \"src/FeeHook.sol\";\nimport {Token} from \"src/Token.sol\";\n\n/// Minimal reserve token standing in for ARB (plain ERC20, no callbacks, no fee).\ncontract ReserveToken {\n    uint256 public totalSupply;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 a) external {\n        balanceOf[to] += a;\n        totalSupply += a;\n    }\n\n    function approve(address s, uint256 a) external returns (bool) {\n        allowance[msg.sender][s] = a;\n        return true;\n    }\n\n    function transfer(address to, uint256 a) external returns (bool) {\n        balanceOf[msg.sender] -= a;\n        balanceOf[to] += a;\n        return true;\n    }\n\n    function transferFrom(address f, address to, uint256 a) external returns (bool) {\n        if (allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= a;\n        balanceOf[f] -= a;\n        balanceOf[to] += a;\n        return true;\n    }\n}\n\n/// Local deployment of the whole stack: PoolManager, Token (currency0), reserve (currency1),\n/// FeeHook at a flag-matching address, FeeVault owned by the strategy, wide LP liquidity at tick 0.\nabstract contract Harness is Test {\n    using StateLibrary for IPoolManager;\n\n    PoolManager pm;\n    Token token;\n    ReserveToken arb;\n    FeeVault vault;\n    FeeHook hook;\n    DrawdownBurnStrategy strat;\n    PoolSwapTest swapRouter;\n    PoolModifyLiquidityTest lpRouter;\n    PoolKey key;\n    PoolId id;\n\n    uint256 constant SUPPLY = 1_000_000_000 ether;\n    int24 constant SPACING = 60;\n    address owner = address(0xA11CE);\n\n    function setUp() public virtual {\n        vm.warp(1_700_000_000);\n        vm.roll(1000);\n        pm = new PoolManager(address(this));\n        token = new Token(\"T\", \"T\", SUPPLY, address(this));\n        arb = new ReserveToken();\n        while (address(arb) < address(token)) arb = new ReserveToken();\n        vault = new FeeVault(address(this));\n        address hookAddr = address(uint160(0x1234 << 20) | uint160(0x44)); // afterSwap | afterSwapReturnDelta\n        deployCodeTo(\"FeeHook.sol:FeeHook\", abi.encode(pm, address(vault), address(arb), 330, owner), hookAddr);\n        hook = FeeHook(hookAddr);\n        key = PoolKey(Currency.wrap(address(token)), Currency.wrap(address(arb)), 3000, SPACING, IHooks(hookAddr));\n        id = key.toId();\n        pm.initialize(key, TickMath.getSqrtPriceAtTick(0)); // 1 reserve per token\n\n        swapRouter = new PoolSwapTest(pm);\n        lpRouter = new PoolModifyLiquidityTest(pm);\n        arb.mint(address(this), 1e12 ether);\n        arb.approve(address(lpRouter), type(uint256).max);\n        arb.approve(address(swapRouter), type(uint256).max);\n        token.approve(address(lpRouter), type(uint256).max);\n        token.approve(address(swapRouter), type(uint256).max);\n        lpRouter.modifyLiquidity(key, ModifyLiquidityParams(-887220, 887220, 10_000_000 ether, 0), \"\");\n    }\n\n    function deployStrategy(uint256 ath, uint256 maxPerBuy) internal {\n        address[] memory ex = new address[](0);\n        strat = new DrawdownBurnStrategy(pm, vault, key, token, address(arb), ath, ex, maxPerBuy, 600, owner);\n        vault.setStrategy(strat);\n        vault.transferOwnership(address(strat));\n    }\n\n    function sell(uint256 tokensIn) internal {\n        swapRouter.swap(\n            key, SwapParams(true, -int256(tokensIn), TickMath.MIN_SQRT_PRICE + 1), PoolSwapTest.TestSettings(false, false), \"\"\n        );\n    }\n\n    function buy(uint256 arbIn) internal {\n        swapRouter.swap(\n            key, SwapParams(false, -int256(arbIn), TickMath.MAX_SQRT_PRICE - 1), PoolSwapTest.TestSettings(false, false), \"\"\n        );\n    }\n\n    function tick() internal view returns (int24 t) {\n        (, t,,) = IPoolManager(address(pm)).getSlot0(id);\n    }\n}\n\n/// Finding: withdrawFromVault reconciles pot/pendingWall against the vault balance before the\n/// reserve that arrived without a hook call has been split, so the next _observe() credits reserve\n/// that is already gone. Expected: pot + pendingWall never exceeds the vault's balance, and\n/// buildWall keeps working.\ncontract WithdrawBeforeObserveDesyncs is Harness {\n    function test_pot_plus_pending_exceeds_vault_after_withdraw() public {\n        deployStrategy(0, 1 ether);\n        sell(1 ether);\n\n        // reserve lands in the vault with no hook call (direct transfer / bonding-curve fee)\n        arb.transfer(address(vault), 100 ether);\n\n        // owner takes it out through the strategy's own accessor\n        vm.prank(owner);\n        strat.withdrawFromVault(address(arb), owner, 100 ether);\n\n        // the next reading splits the 100 that was withdrawn\n        vm.roll(1001);\n        vault.onFeeReceived(address(arb));\n\n        assertLe(strat.pot() + strat.pendingWall(), arb.balanceOf(address(vault)), \"counted reserve exceeds vault balance\");\n        strat.buildWall(); // must not revert\n    }\n}","reproduction":"After one sell, transfer 100 reserve directly to the vault (no hook call). Owner calls withdrawFromVault(reserve, owner, 100). Next block, any fee event / vault.onFeeReceived. Expected: pot + pendingWall <= vault balance and buildWall succeeds. Actual (test/scratch/Proof3_WithdrawDesync.t.sol): pot + pendingWall = 100.0247 while the vault holds 0.0247; buildWall() reverts (vault.withdraw underflows).","severity":"low","snippet":"        vault.withdraw(token_, to, amount);","title":"withdrawFromVault reconciles pot/pendingWall before unsplit reserve is observed, leaving pot + pendingWall above the vault balance and buildWall reverting"},{"citation":"resolved","description":"onFeeReceived takes the burn branch whenever the strategy holds any token, then returns. Anyone can keep the strategy's token balance non-zero by transferring 1 wei to it (cheap on Arbitrum, and no mempool is needed: it can be done every L2 block). Every in-swap call then burns the dust and returns, so the pot is never spent on the trader's gas and buybacks only happen when someone pays for buyback(). The documented behaviour 'runs inside the swap that brought the fee' is defeated at negligible cost; a seller who wants to dump without the buyback bidding under them can do this right before selling. Fix: after a burn that leaves enough gas, continue to the buyback (gas permitting), or only take the burn-only branch when the held amount is at least what a buyback bought (track `heldFromBuy`), treating smaller balances as dust to burn alongside the next buy.","line":250,"path":"src/DrawdownBurnStrategy.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 {PoolId} from \"v4-core/src/types/PoolId.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\nimport {SwapParams, ModifyLiquidityParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {TickMath} from \"v4-core/src/libraries/TickMath.sol\";\nimport {StateLibrary} from \"v4-core/src/libraries/StateLibrary.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\n\nimport {DrawdownBurnStrategy} from \"src/DrawdownBurnStrategy.sol\";\nimport {FeeVault} from \"src/FeeVault.sol\";\nimport {FeeHook} from \"src/FeeHook.sol\";\nimport {Token} from \"src/Token.sol\";\n\n/// Minimal reserve token standing in for ARB (plain ERC20, no callbacks, no fee).\ncontract ReserveToken {\n    uint256 public totalSupply;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 a) external {\n        balanceOf[to] += a;\n        totalSupply += a;\n    }\n\n    function approve(address s, uint256 a) external returns (bool) {\n        allowance[msg.sender][s] = a;\n        return true;\n    }\n\n    function transfer(address to, uint256 a) external returns (bool) {\n        balanceOf[msg.sender] -= a;\n        balanceOf[to] += a;\n        return true;\n    }\n\n    function transferFrom(address f, address to, uint256 a) external returns (bool) {\n        if (allowance[f][msg.sender] != type(uint256).max) allowance[f][msg.sender] -= a;\n        balanceOf[f] -= a;\n        balanceOf[to] += a;\n        return true;\n    }\n}\n\n/// Local deployment of the whole stack: PoolManager, Token (currency0), reserve (currency1),\n/// FeeHook at a flag-matching address, FeeVault owned by the strategy, wide LP liquidity at tick 0.\nabstract contract Harness is Test {\n    using StateLibrary for IPoolManager;\n\n    PoolManager pm;\n    Token token;\n    ReserveToken arb;\n    FeeVault vault;\n    FeeHook hook;\n    DrawdownBurnStrategy strat;\n    PoolSwapTest swapRouter;\n    PoolModifyLiquidityTest lpRouter;\n    PoolKey key;\n    PoolId id;\n\n    uint256 constant SUPPLY = 1_000_000_000 ether;\n    int24 constant SPACING = 60;\n    address owner = address(0xA11CE);\n\n    function setUp() public virtual {\n        vm.warp(1_700_000_000);\n        vm.roll(1000);\n        pm = new PoolManager(address(this));\n        token = new Token(\"T\", \"T\", SUPPLY, address(this));\n        arb = new ReserveToken();\n        while (address(arb) < address(token)) arb = new ReserveToken();\n        vault = new FeeVault(address(this));\n        address hookAddr = address(uint160(0x1234 << 20) | uint160(0x44)); // afterSwap | afterSwapReturnDelta\n        deployCodeTo(\"FeeHook.sol:FeeHook\", abi.encode(pm, address(vault), address(arb), 330, owner), hookAddr);\n        hook = FeeHook(hookAddr);\n        key = PoolKey(Currency.wrap(address(token)), Currency.wrap(address(arb)), 3000, SPACING, IHooks(hookAddr));\n        id = key.toId();\n        pm.initialize(key, TickMath.getSqrtPriceAtTick(0)); // 1 reserve per token\n\n        swapRouter = new PoolSwapTest(pm);\n        lpRouter = new PoolModifyLiquidityTest(pm);\n        arb.mint(address(this), 1e12 ether);\n        arb.approve(address(lpRouter), type(uint256).max);\n        arb.approve(address(swapRouter), type(uint256).max);\n        token.approve(address(lpRouter), type(uint256).max);\n        token.approve(address(swapRouter), type(uint256).max);\n        lpRouter.modifyLiquidity(key, ModifyLiquidityParams(-887220, 887220, 10_000_000 ether, 0), \"\");\n    }\n\n    function deployStrategy(uint256 ath, uint256 maxPerBuy) internal {\n        address[] memory ex = new address[](0);\n        strat = new DrawdownBurnStrategy(pm, vault, key, token, address(arb), ath, ex, maxPerBuy, 600, owner);\n        vault.setStrategy(strat);\n        vault.transferOwnership(address(strat));\n    }\n\n    function sell(uint256 tokensIn) internal {\n        swapRouter.swap(\n            key, SwapParams(true, -int256(tokensIn), TickMath.MIN_SQRT_PRICE + 1), PoolSwapTest.TestSettings(false, false), \"\"\n        );\n    }\n\n    function buy(uint256 arbIn) internal {\n        swapRouter.swap(\n            key, SwapParams(false, -int256(arbIn), TickMath.MAX_SQRT_PRICE - 1), PoolSwapTest.TestSettings(false, false), \"\"\n        );\n    }\n\n    function tick() internal view returns (int24 t) {\n        (, t,,) = IPoolManager(address(pm)).getSlot0(id);\n    }\n}\n\n/// Finding: one wei of the token sent to the strategy flips onFeeReceived into its burn branch,\n/// so the buyback that should run inside this swap is skipped. Expected: the pot is spent on the\n/// swap that brought the fee, as documented.\ncontract DustBlocksInsideBuyback is Harness {\n    function test_one_wei_donation_skips_in_swap_buyback() public {\n        deployStrategy(0, 1_000 ether);\n        sell(1000 ether); // first fee; its pot share is bought back inside this swap\n        vm.roll(1001);\n        assertEq(strat.pot(), 0);\n\n        token.transfer(address(strat), 1); // griefer's dust\n        sell(1000 ether); // new fee, new block: a buyback is due inside this swap\n\n        assertEq(strat.pot(), 0, \"in-swap buyback skipped because of 1 wei of donated token\");\n    }\n}","reproduction":"Sell 1000 tokens in block 1000 (pot share bought back in-swap, pot == 0). Block 1001: griefer transfers 1 wei of token to the strategy, then a sell of 1000 tokens arrives. Expected: the sell's fee is split and the pot (8.22 reserve) is spent inside that swap. Actual (test/scratch/Proof4_DustGrief.t.sol): the call burns 1 wei and returns; pot == 8.22 after the swap.","severity":"low","snippet":"        if (token.balanceOf(address(this)) != 0) {","title":"One wei of token sent to the strategy turns the in-swap call into a burn and skips the buyback that swap should have run"},{"citation":"resolved","description":"buyback() refuses to run while the PoolManager is unlocked, but FeeVault.onFeeReceived(address) has no access control and the strategy only checks msg.sender == vault. A contract can call poolManager.unlock(), and inside its unlockCallback call vault.onFeeReceived(x): the strategy then observes, anchors the block, burns held tokens or runs buyInside with its swap, sync, withdraw and settle inside the caller's unlock, with the caller free to swap before and after it in the same transaction. The 200-tick limit and the 3.3% fee make a sandwich unprofitable in the harness (price band ~2% vs 6.6% round-trip fees), so this is a guard-bypass and a free re-anchoring primitive (used in the ATH finding) rather than a direct loss. It also means the strategy's state writes after the external calls are reachable from arbitrary callers' unlocks, contrary to the comment at line 80-81. Fix: in onFeeReceived require that the vault is being called from the hook's afterSwap (e.g. have the vault pass msg.sender and check it is the hook), or drop the PoolLocked check and document the primitive.","line":269,"path":"src/DrawdownBurnStrategy.sol","reproduction":"Deploy a Runner contract whose unlockCallback calls vault.onFeeReceived(reserve). With the strategy holding bought tokens from the previous block, Runner.run() -> poolManager.unlock -> callback -> vault.onFeeReceived: the strategy executes inside Runner's unlock (observed in test/scratch/Explore.t.sol test_buyback_inside_foreign_unlock: the held tokens are burned and the anchor is rewritten with no swap and no fee). Expected: the strategy acts only from the hook's afterSwap or its own unlock. Actual: any unlock, any caller.","severity":"low","snippet":"        if (poolManager.isUnlocked()) revert PoolLocked();","title":"buyback()'s PoolLocked guard is bypassable: FeeVault.onFeeReceived is permissionless, so the strategy's buyback runs inside any caller's unlock"},{"citation":"resolved","description":"On the local harness (one wide position, zero excluded, no tick crossed) FeeVault.onFeeReceived costs 319k and the strategy's onFeeReceived 314k. Each excluded address adds a cold balanceOf (~2.6k) and each initialized tick the 200-tick buyback crosses adds roughly 10-15k. With 8 excluded addresses and a few LP positions whose edges sit inside the buyback band the call exceeds 400k, the try/catch in the vault swallows it, the split is retried next time (no loss) and buybacks depend on buyback(). This is the author's acknowledged fallback; recorded here with the measured headroom so the live tick spacing can be checked against it.","line":257,"path":"src/DrawdownBurnStrategy.sol","reproduction":"Run test/scratch/Explore.t.sol test_sanity_fee_and_buyback with -vvvv: `[319329] FeeVault::onFeeReceived` / `[313927] DrawdownBurnStrategy::onFeeReceived` / `[246886] buyInside`. Expected per README: ~327k peak on the live pool; adding 8 excluded reads (+~21k warm, more cold) and 4 crossed ticks (+~50k) exceeds 400k.","severity":"info","snippet":"            try this.buyInside(amount) {} catch {}","title":"In-swap path measures 314k gas with no excluded addresses and no tick crossings, leaving ~86k before the 400k cap"}],"hash":"a6017476373a6df42f17bcf3ec830004c2ac7448a96275173e3d80e2fa7c18f9","nodeId":"fe00e028-e309-451f-a791-b7dbd535532b","outcome":"completed","summary":"The review is complete and `.imd-findings.json` holds six findings, four with failing Foundry proofs under `test/scratch/`. No tracked file was changed.\n\n**What I found in `src/DrawdownBurnStrategy.sol`**\n\n- **Medium, line 222.** The constructor baselines `accounted` on `vault.totalReceived` without syncing the vault first. Reserve already in the vault but not yet credited by `sync` is treated as a fresh fee by the first `_observe` and spent. Proof: with 100 synced plus 50 unsynced reserve, one 1-token sell makes the strategy count 50.033 as fees and the in-swap buyback spends 12.5 of the bank.\n- **Medium, line 382.** The hourly close is whatever the first reading of the hour's last block saw. A pump past 200 ticks and a dump inside that one block lifts the ATH by the full step. Proof: ATH rises 2.0% from a wick that never persisted. The natspec also claims stepping costs a fee round trip per block, but `FeeVault.onFeeReceived` is permissionless and fee-free, and 40 such calls walked the anchor from +199 to +3636 after one pump.\n- **Low, line 657.** `withdrawFromVault` reconciles pot and pendingWall before reserve that arrived without a hook call has been split. Afterwards pot plus pendingWall exceeds the vault balance and `buildWall` reverts. I verified the correct fix is to sync the vault and then observe before withdrawing. Observing alone does not fix it.\n- **Low, line 250.** One wei of token sent to the strategy flips the in-swap call into the burn branch, so the buyback that swap should run is skipped. Proof shows the pot left untouched at 8.22 reserve.\n- **Low, line 269.** The `PoolLocked` guard on `buyback()` is bypassable because `FeeVault.onFeeReceived` has no access control. The strategy observed, re-anchored and burned from inside a third party's unlock. Sandwiching it is not profitable at the 200-tick band and 3.3% fee, so this is a guard bypass and the free re-anchoring primitive used above.\n- **Info, line 257.** The in-swap path measures 314k gas with no excluded addresses and no tick crossings, leaving about 86k under the 400k cap.\n\n**What held up under testing.** Every unlock path settles to zero deltas, including crossed-wall burn, in-range withdraw, `moveWall` and `burnWall`. `_toVault` correctly bumps `accounted` so returned reserve is not re-split, and pot plus pendingWall stayed equal to the vault balance across the full wall lifecycle. The wall target math placed the top at tick -9180 for a 40% level, as expected. Sandwiching the buyback loses money because the price band is about 2% against 6.6% round-trip fees.\n\n**Limits.** All tests run on a local harness with one wide LP position, tick spacing 60 and a plain reserve token, not against the live pool. The gas figure therefore understates the live cost with excluded addresses and crossed ticks.","treeHash":null,"usage":{"cachedInputTokens":1957263,"inputTokens":578,"model":"claude-fable-5-1","outputTokens":76156,"runtime":"claude","turns":29,"wallClockMs":1118274}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"03845cacb54c3a73","findings":[{"citation":"resolved","description":"The bank is the vault's ARB balance at install and the README states it must never be spent. `accounted` is frozen at `vault.totalReceived(reserve)`, but that counter only reflects what FeeVault.sync has already credited: reserve that reached the vault by plain transfer and was not synced (vault.pending(reserve) > 0) is not in it. The next sync, which anyone can call and which also runs inside every vault.withdraw and every _toVault, credits it; `_observe` then reads `total - accounted` as a fresh fee, splits it into pot/pendingWall at the block's tier, and a buyback or buildWall spends it. Nothing in FeeVault forces a sync before the strategy is deployed.","line":222,"path":"src/DrawdownBurnStrategy.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.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\";\nimport {SwapParams, ModifyLiquidityParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\nimport {Token} from \"src/Token.sol\";\nimport {FeeVault} from \"src/FeeVault.sol\";\nimport {FeeHook} from \"src/FeeHook.sol\";\nimport {DrawdownBurnStrategy} from \"src/DrawdownBurnStrategy.sol\";\n\ncontract MockARB is ERC20 {\n    constructor() ERC20(\"Arbitrum\", \"ARB\") {\n        _mint(msg.sender, 1e30);\n    }\n}\n\n/// @dev Finding: the constructor snapshots `vault.totalReceived(reserve)` without syncing the vault\n///      first, so reserve already sitting in the vault but not yet credited (balance > lastBalance)\n///      is counted as a fresh fee by the first `_observe` and then spent. The README states the\n///      pre-existing vault balance must never be spent.\ncontract BankSpentTest is Test {\n    address constant TOKEN_ADDR = address(0x1000);\n    // bits 6 (afterSwap) and 2 (afterSwapReturnDelta) set, nothing else in the low 14 bits\n    address constant HOOK_ADDR = address(uint160((uint160(0xC0FFEE) << 14) | 0x44));\n    int24 constant START_TICK = -69060; // ~0.001 ARB per token\n\n    PoolManager pm;\n    MockARB arb;\n    Token token;\n    FeeVault vault;\n    PoolSwapTest swapRouter;\n    PoolModifyLiquidityTest lpRouter;\n    PoolKey key;\n    DrawdownBurnStrategy strat;\n\n    function setUp() public {\n        pm = new PoolManager(address(this));\n        arb = new MockARB();\n        require(TOKEN_ADDR < address(arb), \"ordering\");\n        deployCodeTo(\"Token.sol:Token\", abi.encode(\"Token\", \"TKN\", uint256(1e27), address(this)), TOKEN_ADDR);\n        token = Token(TOKEN_ADDR);\n        vault = new FeeVault(address(this));\n        deployCodeTo(\n            \"FeeHook.sol:FeeHook\", abi.encode(pm, address(vault), address(arb), uint256(330), address(this)), HOOK_ADDR\n        );\n        key = PoolKey(Currency.wrap(TOKEN_ADDR), Currency.wrap(address(arb)), 3000, 60, IHooks(HOOK_ADDR));\n        pm.initialize(key, TickMath.getSqrtPriceAtTick(START_TICK));\n\n        swapRouter = new PoolSwapTest(pm);\n        lpRouter = new PoolModifyLiquidityTest(pm);\n        token.approve(address(swapRouter), type(uint256).max);\n        token.approve(address(lpRouter), type(uint256).max);\n        arb.approve(address(swapRouter), type(uint256).max);\n        arb.approve(address(lpRouter), type(uint256).max);\n        lpRouter.modifyLiquidity(key, ModifyLiquidityParams(-138120, 0, 1e21, 0), \"\");\n    }\n\n    function test_uncreditedBankIsSpent() public {\n        // The bank: 1000 ARB that reached the vault by plain transfer and was never synced.\n        // (FeeVault.sync is permissionless, nothing forces it to have run before deployment.)\n        arb.transfer(address(vault), 1000e18);\n        assertEq(vault.totalReceived(address(arb)), 0);\n        assertEq(vault.pending(address(arb)), 1000e18);\n\n        address[] memory excluded;\n        strat = new DrawdownBurnStrategy(pm, vault, key, token, address(arb), 0, excluded, 1e21, 600, address(this));\n        vault.setStrategy(strat);\n        vault.transferOwnership(address(strat));\n\n        // Any later sync credits the bank as if it were a new fee; the next notification splits it.\n        vault.sync(address(arb));\n        vault.onFeeReceived(address(arb));\n\n        // At the ATH the tier is 0%, so the whole \"fee\" is queued for the wall and laid by anyone.\n        strat.buildWall();\n\n        // Expected: the pre-existing 1000 ARB stays in the vault. Actual: it is spent on the wall.\n        assertEq(arb.balanceOf(address(vault)), 1000e18, \"pre-existing vault balance was spent\");\n    }\n}","reproduction":"State: vault.totalReceived[ARB] == lastBalance[ARB] == 0 and 1000 ARB transferred straight to the vault (vault.pending(ARB) == 1000e18). Deploy DrawdownBurnStrategy, set it as the vault's strategy and owner. Call vault.sync(ARB), then vault.onFeeReceived(ARB): pendingWall() == 1000e18 (the tier is 0% at the ATH). Call buildWall(): the vault's ARB balance goes from 1000e18 to 0. Expected: pot()+pendingWall() == 0 and the 1000 ARB stays in the vault. Fix: call vault_.sync(reserve_) in the constructor before reading totalReceived (equivalently accounted = totalReceived + vault_.pending(reserve_)). Proof test: test/scratch/BankSpent.t.sol fails now and passes with that one-line change.","severity":"medium","snippet":"        accounted = vault_.totalReceived(reserve_);","title":"Constructor snapshots totalReceived without syncing the vault, so uncredited pre-existing ARB is split and spent"},{"citation":"resolved","description":"The constructor leaves anchor.block_ == 0, and `_stepped` returns the raw pool tick in that state, although the constructor already wrote a tick to step from. The first `_observe` after deployment (triggered by the first swap's afterSwap, or by anyone through the permissionless FeeVault.onFeeReceived) therefore anchors the block wherever the pool is at that moment, with no MAX_ANCHOR_STEP limit. lastMcap is set to that reading; if it is the last reading of its clock hour, the next hour's first reading promotes it to athMcap. The contract promises that a wick inside an hour is never an ATH and that the anchor moves at most ~2% a block; here one transaction (pump, trigger, dump) does it. athMcap can never be lowered: a 3.8x fake ATH puts every later fee in the 100% burn tier and places the wall target far above the price, so buildWall never adds liquidity, until the real market cap recovers or the strategy is replaced.","line":416,"path":"src/DrawdownBurnStrategy.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.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\";\nimport {SwapParams, ModifyLiquidityParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\nimport {Token} from \"src/Token.sol\";\nimport {FeeVault} from \"src/FeeVault.sol\";\nimport {FeeHook} from \"src/FeeHook.sol\";\nimport {DrawdownBurnStrategy} from \"src/DrawdownBurnStrategy.sol\";\n\ncontract MockARB is ERC20 {\n    constructor() ERC20(\"Arbitrum\", \"ARB\") {\n        _mint(msg.sender, 1e30);\n    }\n}\n\n/// @dev Finding: `_stepped` returns the raw pool tick when `anchor.block_ == 0`, which is the state\n///      the constructor leaves behind. The very first observation after deployment is therefore not\n///      limited to MAX_ANCHOR_STEP from the deploy-time tick: a single-transaction pump sets the\n///      anchor and `lastMcap` at the pumped price, and the next hour's first reading promotes that\n///      wick to the ATH. The documented guarantee is that a wick inside an hour is never an ATH and\n///      that the anchor moves at most ~2% per block.\ncontract UnsteppedFirstAnchorTest is Test {\n    address constant TOKEN_ADDR = address(0x1000);\n    address constant HOOK_ADDR = address(uint160((uint160(0xC0FFEE) << 14) | 0x44));\n    int24 constant START_TICK = -69060;\n\n    PoolManager pm;\n    MockARB arb;\n    Token token;\n    FeeVault vault;\n    PoolSwapTest swapRouter;\n    PoolModifyLiquidityTest lpRouter;\n    PoolKey key;\n    DrawdownBurnStrategy strat;\n\n    function setUp() public {\n        pm = new PoolManager(address(this));\n        arb = new MockARB();\n        require(TOKEN_ADDR < address(arb), \"ordering\");\n        deployCodeTo(\"Token.sol:Token\", abi.encode(\"Token\", \"TKN\", uint256(1e27), address(this)), TOKEN_ADDR);\n        token = Token(TOKEN_ADDR);\n        vault = new FeeVault(address(this));\n        deployCodeTo(\n            \"FeeHook.sol:FeeHook\", abi.encode(pm, address(vault), address(arb), uint256(330), address(this)), HOOK_ADDR\n        );\n        key = PoolKey(Currency.wrap(TOKEN_ADDR), Currency.wrap(address(arb)), 3000, 60, IHooks(HOOK_ADDR));\n        pm.initialize(key, TickMath.getSqrtPriceAtTick(START_TICK));\n\n        swapRouter = new PoolSwapTest(pm);\n        lpRouter = new PoolModifyLiquidityTest(pm);\n        token.approve(address(swapRouter), type(uint256).max);\n        token.approve(address(lpRouter), type(uint256).max);\n        arb.approve(address(swapRouter), type(uint256).max);\n        arb.approve(address(lpRouter), type(uint256).max);\n        lpRouter.modifyLiquidity(key, ModifyLiquidityParams(-138120, 0, 1e21, 0), \"\");\n\n        vm.warp(1_000_000 * 3600); // a clean hour boundary, well away from zero\n        vm.roll(100);\n        address[] memory excluded;\n        strat = new DrawdownBurnStrategy(pm, vault, key, token, address(arb), 0, excluded, 1e21, 600, address(this));\n        vault.setStrategy(strat);\n        vault.transferOwnership(address(strat));\n    }\n\n    function test_singleTxPumpBecomesAth() public {\n        uint256 mcap0 = strat.marketCap();\n        assertEq(strat.athMcap(), mcap0);\n\n        // Same block as deployment, one transaction: pump ~3.8x (30 ARB in against L = 1e21), which\n        // notifies the strategy from afterSwap and anchors the block at the pumped tick unstepped.\n        uint256 before = token.balanceOf(address(this));\n        swapRouter.swap(\n            key,\n            SwapParams(false, -int256(30e18), TickMath.MAX_SQRT_PRICE - 1),\n            PoolSwapTest.TestSettings(false, false),\n            \"\"\n        );\n        uint256 bought = token.balanceOf(address(this)) - before;\n        (uint64 anchoredBlock,,,,) = strat.anchor();\n        assertEq(anchoredBlock, block.number);\n        assertGt(strat.marketCap(), mcap0 * 3, \"pump should have moved the pool far past one step\");\n\n        // Dump back in the same transaction; the block's anchor is already written.\n        swapRouter.swap(\n            key,\n            SwapParams(true, -int256(bought), TickMath.MIN_SQRT_PRICE + 1),\n            PoolSwapTest.TestSettings(false, false),\n            \"\"\n        );\n\n        // Next hour: the first reading closes the previous hour at its last reading, the pumped one.\n        vm.warp(block.timestamp + 1 hours);\n        vm.roll(block.number + 1);\n        vault.onFeeReceived(address(arb)); // permissionless on the vault; forwards to the strategy\n\n        // Expected: the first anchor is stepped from the deploy tick, so the hourly close and the\n        // ATH can be at most ~2.02% (200 ticks) above the deploy market cap. Actual: ~3.8x.\n        assertLe(strat.athMcap(), mcap0 * 10202 / 10000, \"a single-tx wick became the ATH\");\n    }\n}","reproduction":"Deploy at tick -69060 with athMcap_ = 0 (ath = mcap0). In the same block, one transaction: buy with 30 ARB against L = 1e21 (pool ~3.8x higher), which calls FeeVault.onFeeReceived from afterSwap and anchors the block unstepped; sell the tokens back. Warp one hour and call vault.onFeeReceived(ARB). Actual: athMcap == 3.79e24 (3.8x mcap0). Expected with stepping from the constructor's tick: athMcap <= mcap0 * 1.0202 (200 ticks). Fix: drop the `a.block_ == 0` early return (the constructor always writes a tick), or store a nonzero sentinel block in the constructor. Proof: test/scratch/UnsteppedFirstAnchor.t.sol fails now and passes with the early return removed.","severity":"medium","snippet":"        if (a.block_ == 0) return tick;","title":"First observation after deployment is not stepped, so a single-transaction pump sets the anchor and becomes the hourly close and the ATH"},{"citation":"resolved","description":"The buyback that runs inside a trader's swap is a nested poolManager.swap under the vault's 400k gas cap. The nested swap pays for every initialized tick it crosses between the current price and the 200-tick limit (tick-info reads, bitmap reads, feeGrowthOutside writes). Anyone can initialize every tickSpacing boundary around the price with liquidityDelta = 1: 41 positions over +-1200 ticks cost one transaction and are permanent. In the harness the same in-swap call measures 265,653 gas and buys on the clean pool, and 388,321+ gas with the positions in place, at which point buyInside runs out of gas, the try/catch swallows it, and nothing is bought although the pot holds over 10 ARB. The harness is cheaper than mainnet (plain ERC20 instead of the ARB proxy, one excluded address), so the real margin is smaller. Every swap after that still burns the full 400k on the trader's gas. buyback() on a keeper's gas still works, so the loss is the automation the README leans on, plus the gas griefing of all traders.","line":257,"path":"src/DrawdownBurnStrategy.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.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\";\nimport {StateLibrary} from \"v4-core/src/libraries/StateLibrary.sol\";\nimport {SwapParams, ModifyLiquidityParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\nimport {Token} from \"src/Token.sol\";\nimport {FeeVault} from \"src/FeeVault.sol\";\nimport {FeeHook} from \"src/FeeHook.sol\";\nimport {DrawdownBurnStrategy} from \"src/DrawdownBurnStrategy.sol\";\n\ncontract MockARB is ERC20 {\n    constructor() ERC20(\"Arbitrum\", \"ARB\") {\n        _mint(msg.sender, 1e30);\n    }\n}\n\n/// @dev Finding: the in-swap buyback (`onFeeReceived` -> `buyInside`) runs under the vault's 400k\n///      gas cap. Its nested swap pays for every initialized tick it crosses on the way to the\n///      200-tick limit. Anyone can initialize every spacing boundary around the price with 1 wei of\n///      liquidity, after which the nested swap runs out of gas, `buyInside` reverts, the try/catch\n///      swallows it, and no buyback happens inside swaps although the pot is full. Each trader still\n///      pays the full 400k. The same pool without those positions buys fine at ~266k.\ncontract TickStarvedBuybackTest is Test {\n    using StateLibrary for PoolManager;\n\n    address constant TOKEN_ADDR = address(0x1000);\n    address constant HOOK_ADDR = address(uint160((uint160(0xC0FFEE) << 14) | 0x44));\n    int24 constant START_TICK = -69060;\n\n    PoolManager pm;\n    MockARB arb;\n    Token token;\n    FeeVault vault;\n    PoolSwapTest swapRouter;\n    PoolModifyLiquidityTest lpRouter;\n    PoolKey key;\n    DrawdownBurnStrategy strat;\n    address attacker = address(0xA77);\n\n    function setUp() public {\n        pm = new PoolManager(address(this));\n        arb = new MockARB();\n        require(TOKEN_ADDR < address(arb), \"ordering\");\n        deployCodeTo(\"Token.sol:Token\", abi.encode(\"Token\", \"TKN\", uint256(1e27), address(this)), TOKEN_ADDR);\n        token = Token(TOKEN_ADDR);\n        vault = new FeeVault(address(this));\n        deployCodeTo(\n            \"FeeHook.sol:FeeHook\", abi.encode(pm, address(vault), address(arb), uint256(330), address(this)), HOOK_ADDR\n        );\n        key = PoolKey(Currency.wrap(TOKEN_ADDR), Currency.wrap(address(arb)), 3000, 60, IHooks(HOOK_ADDR));\n        pm.initialize(key, TickMath.getSqrtPriceAtTick(START_TICK));\n        swapRouter = new PoolSwapTest(pm);\n        lpRouter = new PoolModifyLiquidityTest(pm);\n        token.approve(address(swapRouter), type(uint256).max);\n        token.approve(address(lpRouter), type(uint256).max);\n        arb.approve(address(swapRouter), type(uint256).max);\n        arb.approve(address(lpRouter), type(uint256).max);\n        lpRouter.modifyLiquidity(key, ModifyLiquidityParams(-138120, 0, 1e23, 0), \"\");\n\n        vm.warp(1_000_000 * 3600);\n        vm.roll(100);\n        address[] memory excluded = new address[](1);\n        excluded[0] = address(this);\n        strat = new DrawdownBurnStrategy(pm, vault, key, token, address(arb), 0, excluded, 1e21, 600, address(this));\n        vault.setStrategy(strat);\n        vault.transferOwnership(address(strat));\n    }\n\n    function _sell(uint256 amt) internal {\n        swapRouter.swap(\n            key, SwapParams(true, -int256(amt), TickMath.MIN_SQRT_PRICE + 1), PoolSwapTest.TestSettings(false, false), \"\"\n        );\n    }\n\n    /// Sells in a drawdown fill the pot. 1 wei of token on the strategy before each sell makes the\n    /// in-swap call burn-and-return instead of buying, so the pot accumulates (see the dust finding).\n    function _fillPot() internal {\n        for (uint256 i = 0; i < 6; i++) {\n            vm.roll(block.number + 1);\n            token.transfer(address(strat), 1);\n            _sell(1e24);\n        }\n        assertEq(strat.totalSpent(), 0);\n        vm.roll(block.number + 1);\n        strat.burnHeld();\n        assertGt(strat.pot(), 1e19);\n    }\n\n    function test_cleanPool_inSwapBuybackRuns() public {\n        _fillPot();\n        vm.roll(block.number + 1);\n        _sell(1e20);\n        assertGt(strat.totalSpent(), 0, \"baseline buyback did not run\");\n    }\n\n    function test_initializedTicksStarveInSwapBuyback() public {\n        _fillPot();\n        (, int24 tick,,) = pm.getSlot0(key.toId());\n\n        // Attacker: 1 wei of liquidity on every 60-tick boundary across +-1200 ticks (41 positions).\n        token.transfer(attacker, 1e24);\n        arb.transfer(attacker, 1e22);\n        vm.startPrank(attacker);\n        token.approve(address(lpRouter), type(uint256).max);\n        arb.approve(address(lpRouter), type(uint256).max);\n        int24 base = (tick / 60) * 60 - 1200;\n        for (int24 t = base; t <= base + 2400; t += 60) {\n            lpRouter.modifyLiquidity(key, ModifyLiquidityParams(t, t + 60, 1, 0), \"\");\n        }\n        vm.stopPrank();\n\n        vm.roll(block.number + 1);\n        _sell(1e20);\n        // Expected: the pot (>10 ARB) is bought back inside the swap, as on the clean pool.\n        // Actual: buyInside runs out of gas crossing the initialized ticks and is swallowed.\n        assertGt(strat.totalSpent(), 0, \"in-swap buyback silently failed with initialized ticks\");\n    }\n}","reproduction":"Pool: L = 1e23 over [-138120, 0], spacing 60, price tick -69060, hook fee 330 bps, maxPerBuy 1e21. Fill the pot with six 1e24-token sells (keeping 1 wei of token on the strategy during the fills so it burns instead of buying), pot > 1e19. Attacker adds modifyLiquidity(t, t+60, 1) for every t = (tick/60)*60-1200 .. +1200. Next block a 1e20-token sell: expected totalSpent() > 0 (the clean pool buys 1.1e19 ARB in-swap at 265,653 gas); actual totalSpent() == 0, trace shows buyInside -> OutOfGas at 388,321 gas inside the 400k call. Fix options that preserve the design: inside a swap, cap the nested swap's price limit to the next tickSpacing boundary above the current tick (one crossing at most) and leave the rest to buyback(); or skip the in-swap buy when the nearest initialized ticks make it unaffordable. Proof: test/scratch/TickStarvedBuyback.t.sol (second test fails now; the first test is the clean-pool baseline).","severity":"medium","snippet":"            try this.buyInside(amount) {} catch {}","title":"1-wei positions on every spacing boundary push the in-swap buyback past the 400k cap, so it silently never runs while every trader still pays for it"},{"citation":"resolved","description":"vault.withdraw syncs first, so any uncredited reserve in the vault (plain transfers) is added to totalReceived during the withdraw. The reconciliation that follows compares pot+pendingWall against the post-withdraw balance, which still contains that amount, and shrinks them only to the balance. The next `_observe` then splits the same amount (total - accounted) on top, so pot+pendingWall exceeds the vault's reserve by exactly the uncredited amount, permanently. Consequences: `_build` does not cap at the balance, so buildWall reverts in vault.withdraw (ERC20InsufficientBalance) whenever pendingWall exceeds the balance; `_due` caps at the whole balance including any remaining bank, so the phantom pot spends bank ARB, which the README says must never happen.","line":657,"path":"src/DrawdownBurnStrategy.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.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\";\nimport {SwapParams, ModifyLiquidityParams} from \"v4-core/src/types/PoolOperation.sol\";\nimport {PoolSwapTest} from \"v4-core/src/test/PoolSwapTest.sol\";\nimport {PoolModifyLiquidityTest} from \"v4-core/src/test/PoolModifyLiquidityTest.sol\";\nimport {Token} from \"src/Token.sol\";\nimport {FeeVault} from \"src/FeeVault.sol\";\nimport {FeeHook} from \"src/FeeHook.sol\";\nimport {DrawdownBurnStrategy} from \"src/DrawdownBurnStrategy.sol\";\n\ncontract MockARB is ERC20 {\n    constructor() ERC20(\"Arbitrum\", \"ARB\") {\n        _mint(msg.sender, 1e30);\n    }\n}\n\n/// @dev Finding: `withdrawFromVault` reconciles `pot`/`pendingWall` against the vault balance\n///      *after* `vault.withdraw` has synced (credited) any uncredited reserve, but *before* that\n///      reserve has been split by `_observe`. The next `_observe` then adds it on top, so\n///      pot + pendingWall exceeds what the vault holds: `buildWall` reverts on an insufficient\n///      balance, and `_due`'s cap at the vault balance lets a buyback reach into the bank.\ncontract WithdrawOvercountTest is Test {\n    address constant TOKEN_ADDR = address(0x1000);\n    address constant HOOK_ADDR = address(uint160((uint160(0xC0FFEE) << 14) | 0x44));\n    int24 constant START_TICK = -69060;\n\n    PoolManager pm;\n    MockARB arb;\n    Token token;\n    FeeVault vault;\n    PoolSwapTest swapRouter;\n    PoolModifyLiquidityTest lpRouter;\n    PoolKey key;\n    DrawdownBurnStrategy strat;\n\n    function setUp() public {\n        pm = new PoolManager(address(this));\n        arb = new MockARB();\n        require(TOKEN_ADDR < address(arb), \"ordering\");\n        deployCodeTo(\"Token.sol:Token\", abi.encode(\"Token\", \"TKN\", uint256(1e27), address(this)), TOKEN_ADDR);\n        token = Token(TOKEN_ADDR);\n        vault = new FeeVault(address(this));\n        deployCodeTo(\n            \"FeeHook.sol:FeeHook\", abi.encode(pm, address(vault), address(arb), uint256(330), address(this)), HOOK_ADDR\n        );\n        key = PoolKey(Currency.wrap(TOKEN_ADDR), Currency.wrap(address(arb)), 3000, 60, IHooks(HOOK_ADDR));\n        pm.initialize(key, TickMath.getSqrtPriceAtTick(START_TICK));\n\n        swapRouter = new PoolSwapTest(pm);\n        lpRouter = new PoolModifyLiquidityTest(pm);\n        token.approve(address(swapRouter), type(uint256).max);\n        token.approve(address(lpRouter), type(uint256).max);\n        arb.approve(address(swapRouter), type(uint256).max);\n        arb.approve(address(lpRouter), type(uint256).max);\n        lpRouter.modifyLiquidity(key, ModifyLiquidityParams(-138120, 0, 1e21, 0), \"\");\n\n        vm.warp(1_000_000 * 3600);\n        vm.roll(100);\n        address[] memory excluded;\n        strat = new DrawdownBurnStrategy(pm, vault, key, token, address(arb), 0, excluded, 1e21, 600, address(this));\n        vault.setStrategy(strat);\n        vault.transferOwnership(address(strat));\n    }\n\n    function test_withdrawLeavesPotAndPendingAboveBalance() public {\n        // Real fees: a sell pays 3.3% of the ARB out to the vault; the strategy splits it at once.\n        swapRouter.swap(\n            key,\n            SwapParams(true, -int256(1e23), TickMath.MIN_SQRT_PRICE + 1),\n            PoolSwapTest.TestSettings(false, false),\n            \"\"\n        );\n        uint256 counted = strat.pot() + strat.pendingWall();\n        assertGt(counted, 1e17);\n        assertEq(arb.balanceOf(address(vault)), counted);\n\n        // 50 ARB reach the vault by plain transfer and are not synced yet.\n        arb.transfer(address(vault), 50e18);\n\n        // The owner withdraws everything but 0.1 ARB. vault.withdraw syncs first, crediting the 50 ARB\n        // to totalReceived; the strategy then shrinks pot/pendingWall to the 0.1 ARB left...\n        strat.withdrawFromVault(address(arb), address(this), counted + 50e18 - 1e17);\n        assertEq(arb.balanceOf(address(vault)), 1e17);\n        assertEq(strat.pot() + strat.pendingWall(), 1e17);\n\n        // ...and the next notification splits the 50 ARB on top, although it was already withdrawn.\n        vault.onFeeReceived(address(arb));\n\n        // Expected: pot + pendingWall never exceeds the reserve the vault holds for them.\n        // Actual: 50.1 ARB counted against a 0.1 ARB balance.\n        assertLe(strat.pot() + strat.pendingWall(), arb.balanceOf(address(vault)), \"pot+pendingWall > vault balance\");\n    }\n}","reproduction":"After a sell has produced fees F (pot+pendingWall == F == vault balance), transfer 50 ARB to the vault without syncing. Owner calls withdrawFromVault(ARB, owner, F + 50e18 - 1e17): balance is 1e17 and pot+pendingWall is reconciled to 1e17. Call vault.onFeeReceived(ARB): pot+pendingWall == 50.1e18 against a 1e17 balance. Expected: pot+pendingWall <= balance. Fix: after vault.withdraw (which synced), call _observe() so the credited amount is split before comparing, or reconcile against held - (vault.totalReceived(reserve) - accounted). Proof: test/scratch/WithdrawOvercount.t.sol fails now and passes with _observe() inserted after the reserve check.","severity":"low","snippet":"        vault.withdraw(token_, to, amount);","title":"withdrawFromVault reconciles pot/pendingWall before the reserve its own withdraw just credited is split, leaving pot+pendingWall above the vault balance"},{"citation":"resolved","description":"onFeeReceived burns any held token and returns without buying whenever balanceOf(this) != 0, with no minimum. The design intends this for the strategy's own purchases (buy and burn take turns), but anyone can transfer 1 wei of the token to the strategy before each sell, so the in-swap call spends its gas on a 1-wei burn (two checkpoint pushes) and never reaches buyInside. The pot keeps growing and buybacks only happen through buyback() on someone's own gas.","line":250,"path":"src/DrawdownBurnStrategy.sol","reproduction":"Pool with pot building from sells. Before each of six 1e24-token sells, token.transfer(strategy, 1). After the six sells totalSpent() == 0 and pot() > 1e19, while the same sells without the 1-wei transfers run a buyback in the first sell whose block's tier split anything into the pot (seen in test/scratch/TickStarvedBuyback.t.sol, _fillPot vs test_cleanPool_inSwapBuybackRuns). Fix: only take the burn-first branch when the held balance is at least a meaningful threshold (for example what the last buyback bought, or a configurable minimum), and otherwise proceed to the buy; dust is burned by the next unlock path anyway.","severity":"low","snippet":"        if (token.balanceOf(address(this)) != 0) {","title":"1 wei of token sent to the strategy turns the next in-swap call into a burn and blocks the buyback, repeatable for pennies"},{"citation":"resolved","description":"The contract's own model (header comment, lines 46-49) says lifting the ATH means holding the price to an hour's close and paying a round trip through the pool's fee for every block. In fact a reading happens on every call to onFeeReceived, and FeeVault.onFeeReceived is permissionless and calls the strategy with 400k gas, so after a single pump an attacker advances the anchor by MAX_ANCHOR_STEP each block with a plain call and no trade. The cost of lifting the ATH by X% is one pump and one dump plus roughly X/2 cheap calls spread over X/2 L1 blocks (about 35 blocks, 7 minutes, for a 2x), while holding the pumped position; other traders selling into the pump are the only fee risk. This is a correction to the stated cost model rather than a new path; the first-observation finding is the one-transaction case of it.","line":242,"path":"src/DrawdownBurnStrategy.sol","reproduction":"After deployment and one normal reading, pump the pool by 1000 ticks in one transaction. Each following L1 block, call FeeVault.onFeeReceived(address(0)) from any EOA: anchor.tick advances by 200 per block with no swap (watch anchor() and lastMcap()). After 5 blocks lastMcap reflects the full pump; let it be the last reading of the hour, dump, and the next hour's first reading sets athMcap to it. Expected per the header: a pool fee paid every block. Actual: gas only. Fix: document the real cost, and consider requiring the reading inside onFeeReceived to come from the hook path (msg.sender == vault is already checked, but the vault's entry is open) or rate-limiting anchor steps by elapsed time rather than by call.","severity":"low","snippet":"    function onFeeReceived(address) external {","title":"Walking the anchor costs gas only: readings need no swap, so the documented per-block fee for lifting the ATH does not exist"}],"hash":"b7783d1b8ab991266a9e1e6245a14577b7943108e2b0817015c4246ef89df193","nodeId":"64aed185-5fe5-4ca3-ad35-1813468cce99","outcome":"completed","summary":"Review complete. The findings file is at `.imd-findings.json` in the repository root, with six findings, four of them carrying Foundry proofs under `test/scratch/` that fail on the current source and pass on a scratch copy with the respective fix applied. No file outside the findings file and `test/scratch/` was touched; `git diff` is empty.\n\n**What I found, most severe first**\n\n- **Medium, constructor spends uncredited bank.** `accounted` is frozen at `vault.totalReceived` without syncing first, so ARB that reached the vault by plain transfer and was never synced is split as a fresh fee and spent. Proof: 1000 ARB in the vault before deploy ends at 0 after one `buildWall`. One-line fix in the constructor.\n- **Medium, first anchor is unstepped.** With `anchor.block_ == 0` the stepping is skipped, so a single-transaction pump sets the block's anchor anywhere. That reading becomes the hourly close and then the ATH, which can never be lowered. Proof: a 30 ARB pump makes `athMcap` 3.8x the deploy market cap, where the stepping should cap it near 2%.\n- **Medium, cheap gas griefing of the in-swap buyback.** 1-wei liquidity positions on every spacing boundary make the nested buyback swap run out of gas under the 400k cap. Measured: 265,653 gas and a successful buy on a clean pool, out of gas at 388,321 with the positions in place, nothing bought, and every trader still pays the full cap. The `buyback()` fallback still works.\n- **Low, `withdrawFromVault` over-count.** The reconciliation runs after the withdraw's sync credited uncredited ARB but before it is split, so pot plus pendingWall ends above the vault balance. That makes `buildWall` revert and lets the pot reach into the bank. Proof included.\n- **Low, dust blocks in-swap buybacks.** One wei of token on the strategy turns the next in-swap call into a burn. Demonstrated inside the gas proof's setup.\n- **Low, the documented per-block cost for walking the anchor does not exist.** Readings need no swap because the vault's entry point is permissionless, so lifting the ATH costs one round trip plus gas-only calls.\n\n**What held up under review.** The flash-accounting paths all settle to zero, the `getSyncedCurrency` guard and sync-then-settle ordering are sound against the standard router flows, re-entry through the vault during a buyback is correctly cut off by the transient guard, `_toVault` does not double count returned ARB, the single-sided liquidity math never pays more than `pendingWall`, and I found no profitable sandwich on the buyback within the 200-tick limits. The wall tick math and `_floor` are correct for any realistic price; the only revert path is a target price below the minimum tick, which needs an ATH near zero.\n\n**Limits.** All tests use a plain ERC20 for ARB and the real v4 PoolManager without a fork, so gas numbers are lower bounds relative to Arbitrum's ARB proxy. The pool's real tick spacing and liquidity were not available, so the third finding's threshold on mainnet depends on them.","treeHash":null,"usage":{"cachedInputTokens":2119972,"inputTokens":578,"model":"claude-fable-5-1","outputTokens":89049,"runtime":"claude","turns":37,"wallClockMs":1253941}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"559cfaaab2c0d013","findings":[{"citation":"resolved","description":"The contract's defence against lifting the ATH is stated as: the anchor moves at most 200 ticks per block, and holding a pump 'to an hour's close' is 'paid for every block -- a round trip through the pool's fee each time'. That cost claim is wrong. The anchor steps toward the pool's *current* tick on every observation (_anchorNow/_stepped, lines 383-386 and 415-420), and an observation needs no trade: buyback() (line 268) and the vault's unguarded FeeVault.onFeeReceived (which calls back into onFeeReceived, line 242, where _observe() runs before the isUnlocked check) both anchor the block for gas only. So one pump that is simply held lets the attacker step the anchor +200 ticks per block.number (on Arbitrum One block.number is the L1 block number, ~12 s) until it equals the pumped tick, paying the 3.3% hook fee once on entry and once on exit, not per block. If the attacker's observation is the last reading of the clock hour, the next hour's first reading (this line) lifts athMcap to the pumped market cap. athMcap only ever rises (line 398-401) and there is no owner function to lower or reset it, so the effect is permanent: every later tier read (_tierBps) is against the inflated ATH (100% of fees to the pot once real mcap < 40% of it), and wallTarget() places the wall's top at 40% of the inflated ATH, which can sit above the real price so buildWall() returns at `tick < upper` and pending reserve is never laid; moveWall() would take the existing wall down and also fail to re-lay it. The same free walk lets a sandwicher front-run a large pot (e.g. after burnWall()): once the anchor has caught up, buyback() each L1 block spends up to maxPerBuy at the held price, giving the pumper exit liquidity 2% per block.\n\nFix (design decision for the owner): make the hourly close independent of who observes last, e.g. record the close as the minimum/TWAP of the hour's anchors rather than the last reading, require the close reading to be at least N blocks after the last large anchor move, or cap the per-hour ATH rise; and add an owner-only setter/decay for athMcap so a manipulated high can be corrected. At minimum, fix the NatSpec claim that each block of a held pump costs a round-trip fee.","line":382,"path":"src/DrawdownBurnStrategy.sol","reproduction":"State: last anchor at tick 0 (mcap M), athMcap = M. Attacker buys once, pushing the pool to tick +2000 (~22% up) and holds. In each of the next 10 L1 blocks the attacker calls buyback() or FeeVault.onFeeReceived(x) (no trade): the anchor goes 200, 400, ..., 2000. At the first reading of the next clock hour athMcap becomes marketCapAt(2000) ~= 1.22 M. Attacker sells. Expected (per the NatSpec): lifting the ATH costs a fee round trip per block held. Actual: one round trip total; ATH +22% and never reducible. Demonstrated with a pool-manager stub in test/scratch/InstallWindowAndAthWalk.t.sol (test_anchorAndAthWalkWithoutTrading): after 10 observations anchor.tick == 2000 and athMcap > 1.21 * ath0 with zero swaps.","severity":"medium","snippet":"        if (hour != a.hour && mcap > ath) ath = mcap;","title":"Anchor and hourly-close ATH can be walked up by a single held pump plus free per-block observations; the ATH is irreversible and has no owner reset"},{"citation":"resolved","description":"The in-swap buyback is a nested poolManager.swap run inside FeeVault.onFeeReceived's 400k gas allowance, measured at a 327k peak by the authors, leaving ~73k of headroom that third parties control. Measured on a local v4 PoolManager (test/scratch/TickCrossGas.t.sol, no hook, tickSpacing 10): a buyback-shaped swap (currency1 exact-in, limit +200 ticks) costs 139,196 gas with no initialized tick in its path and 368,200 gas when six 1000-wei-liquidity dust positions initialize seven ticks inside it, i.e. about 33k gas per cold initialized tick crossed. Two or three such ticks between the current tick and min(tick, anchor)+200 therefore push buyInside past the allowance; the catch at this line swallows the OutOfGas, the fee is still split so the pot grows, but no buyback runs inside any trader's swap. Anyone can initialize ticks for a one-off cost (the hook has no add-liquidity permission and the positions never need re-funding); with tickSpacing 60 three ticks fit in the 200-tick path, with spacing 10 twenty do, so a griefer who carpets a wide band around the price with dust positions disables the in-swap buyback for good. Impact: the documented 'runs on the trader's gas' automation is lost, buybacks depend on a keeper paying for buyback() (whose gas also rises), and while the keeper waits the pot sits idle and predictable (feeding the sandwich in the anchor finding). The _excluded loop (up to 8 external balanceOf calls) and the held-token burn path draw on the same budget.\n\nFix: check gasleft() against a measured floor before buyInside and skip the swap (not the split) when it is below it; consider a tighter in-swap price limit so fewer ticks can lie on the path; and document buyback() by a keeper as the reliable path.","line":257,"path":"src/DrawdownBurnStrategy.sol","reproduction":"State: pool tickSpacing s <= 60, price at tick T, anchor at T, pot = maxPerBuy. Griefer mints 1000-wei-liquidity positions [T+s, T+2s], [T+2s, T+3s], [T+3s, T+4s] (three initialized ticks inside T..T+200). A trader sells; hook -> vault.onFeeReceived{gas:400k} -> strategy.onFeeReceived -> buyInside. Expected: BoughtBack inside the swap as at the measured 327k. Actual: the nested swap crosses three cold initialized ticks (~33k each, measured 229k for seven), exceeds the allowance, the catch swallows it, only Split is emitted; this repeats on every swap while the positions exist. Measurement: forge test --match-path test/scratch/TickCrossGas.t.sol -vv prints baseline 139,196 vs 368,200 gas.","severity":"medium","snippet":"            try this.buyInside(amount) {} catch {}","title":"In-swap buyback can be pushed past the 400k vault cap permanently by cheap initialized ticks on the buyback's path"},{"citation":"resolved","description":"withdrawFromVault compares pot + pendingWall against the vault's balance after the withdrawal (lines 659-665), but it never calls _observe() first. Reserve that the vault has already recorded in totalReceived but that the strategy has not yet split (its onFeeReceived was cut short by the 400k cap or a low-gas swap, a direct ARB transfer followed by vault.sync(), or fees from the deploy-to-install window) is therefore invisible to the check. When the owner withdraws that money, nothing is deducted; at the next observation the stale fees are split and pot + pendingWall exceed what the vault holds. Consequences: _due() is capped by the vault balance so buybacks keep going but the pot share later consumes reserve that was meant for the wall, and _build() is not capped: when pendingWall exceeds the vault balance, vault.withdraw inside _payFromVault reverts and buildWall() is unusable until enough new fees arrive. The bank itself is not spent by this path (the owner took it), but the strategy's invariant pot + pendingWall <= vault reserve for the strategy is broken by an ordinary owner action.\n\nFix: call _observe() at the start of withdrawFromVault (and/or compute `both` from totalReceived - accounted as well), so the reconciliation sees everything the vault has recorded.","line":657,"path":"src/DrawdownBurnStrategy.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.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 {PoolId, PoolIdLibrary} from \"v4-core/src/types/PoolId.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\n\nimport {DrawdownBurnStrategy} from \"src/DrawdownBurnStrategy.sol\";\nimport {FeeVault} from \"src/FeeVault.sol\";\nimport {Token} from \"src/Token.sol\";\n\n/// @dev Minimal stand-in for the pool manager: only the storage reads the strategy makes outside a\n///      swap (slot0 via extsload, lock / synced currency via exttload) are implemented.\ncontract PoolManagerStub {\n    mapping(bytes32 => bytes32) internal slots;\n\n    function setTick(PoolId id, int24 tick) external {\n        bytes32 stateSlot = keccak256(abi.encodePacked(PoolId.unwrap(id), bytes32(uint256(6))));\n        uint256 sqrtP = 1 << 96; // tick 0\n        slots[stateSlot] = bytes32((uint256(uint24(tick)) << 160) | sqrtP);\n    }\n\n    function extsload(bytes32 slot) external view returns (bytes32) {\n        return slots[slot];\n    }\n\n    function exttload(bytes32) external pure returns (bytes32) {\n        return bytes32(0); // never unlocked, nothing synced\n    }\n}\n\ncontract Reserve is ERC20 {\n    constructor() ERC20(\"ARB\", \"ARB\") {}\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\ncontract WithdrawFromVaultOvercountTest is Test {\n    using PoolIdLibrary for PoolKey;\n\n    PoolManagerStub pm;\n    FeeVault vault;\n    Token token;\n    Reserve arb;\n    DrawdownBurnStrategy strat;\n\n    uint256 constant BANK = 100e18;\n    uint256 constant FEE = 10e18;\n\n    function setUp() public {\n        pm = new PoolManagerStub();\n        vault = new FeeVault(address(this));\n        arb = new Reserve();\n        // Token must sort below ARB to be currency0: redeploy until it does.\n        token = new Token(\"T\", \"T\", 1e27, address(0xBEEF));\n        while (address(token) > address(arb)) token = new Token(\"T\", \"T\", 1e27, address(0xBEEF));\n\n        PoolKey memory key = PoolKey({\n            currency0: Currency.wrap(address(token)),\n            currency1: Currency.wrap(address(arb)),\n            fee: 3000,\n            tickSpacing: 60,\n            hooks: IHooks(address(0))\n        });\n        pm.setTick(key.toId(), 0);\n\n        // The bank: ARB the vault held, and had recorded, before the strategy existed.\n        arb.mint(address(vault), BANK);\n        vault.sync(address(arb));\n\n        address[] memory ex = new address[](0);\n        strat = new DrawdownBurnStrategy(\n            IPoolManager(address(pm)), vault, key, token, address(arb), 0, ex, 1e18, 600, address(this)\n        );\n        vault.setStrategy(strat);\n        vault.transferOwnership(address(strat));\n    }\n\n    /// A fee lands in the vault and is recorded by the vault (as the hook does with take + sync)\n    /// but the strategy has not split it yet (its onFeeReceived was cut short or not reached).\n    /// The owner then withdraws the bank plus part of that fee. The strategy's counters are not\n    /// reduced because they do not yet include the fee; when the fee is split afterwards, pot +\n    /// pendingWall exceeds what the vault actually holds.\n    function test_withdrawFromVault_overcountsAfterUnaccountedFee() public {\n        arb.mint(address(vault), FEE);\n        vault.sync(address(arb));\n        assertEq(vault.totalReceived(address(arb)), BANK + FEE);\n        assertEq(strat.accounted(), BANK);\n\n        // Owner takes the bank and half of the fee that has already arrived.\n        strat.withdrawFromVault(address(arb), address(this), BANK + FEE / 2);\n        assertEq(arb.balanceOf(address(vault)), FEE / 2);\n\n        // Next observation (any swap, buyback(), or this permissionless call) splits the fee.\n        vault.onFeeReceived(address(0));\n\n        uint256 counted = strat.pot() + strat.pendingWall();\n        uint256 held = arb.balanceOf(address(vault));\n        // Expected: the strategy never counts more reserve than the vault holds for it.\n        assertLe(counted, held, \"pot + pendingWall exceeds the vault's reserve balance\");\n    }\n}","reproduction":"Bank 100 ARB synced before deploy (accounted = 100). A 10 ARB fee lands and is recorded by the vault but not yet observed. Owner calls withdrawFromVault(ARB, owner, 105 ARB): held = 5, both = 0, no deduction. Next observation splits 10 ARB: pot + pendingWall = 10 while the vault holds 5. Expected: counters <= 5. Actual: 10. Proof test attached (fails now; passes once withdrawFromVault observes before reconciling).","severity":"low","snippet":"        vault.withdraw(token_, to, amount);","title":"withdrawFromVault reconciles pot/pendingWall before splitting fees already received, so the counters can exceed the vault's balance"},{"citation":"resolved","description":"The baseline for 'fresh' fees is fixed in the constructor, not when the strategy becomes the vault's strategy/owner. Every ARB fee the hook banks between the deploy transaction and vault.setStrategy()/transferOwnership() is later read as fresh by _observe() and split into pot/pendingWall, although by the README's definition it was banked before installation and must never be spent. The window is owner-controlled but not atomic: the vault's current owner must call setStrategy and transferOwnership in separate transactions after deployment, during which trading continues.\n\nFix: add an owner-only `install()`/`start()` step that sets accounted = vault.totalReceived(reserve) at the moment the strategy is activated (or re-baseline inside the first _observe() when vault.strategy() == this and vault.owner() == this for the first time), or document that deployment and installation must be done atomically from a contract.","line":222,"path":"src/DrawdownBurnStrategy.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.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 {PoolId, PoolIdLibrary} from \"v4-core/src/types/PoolId.sol\";\nimport {Currency} from \"v4-core/src/types/Currency.sol\";\n\nimport {DrawdownBurnStrategy} from \"src/DrawdownBurnStrategy.sol\";\nimport {FeeVault} from \"src/FeeVault.sol\";\nimport {Token} from \"src/Token.sol\";\n\n/// @dev Minimal stand-in for the pool manager: only the storage reads the strategy makes outside a\n///      swap (slot0 via extsload, lock / synced currency via exttload) are implemented.\ncontract PoolManagerStub {\n    mapping(bytes32 => bytes32) internal slots;\n\n    function setTick(PoolId id, int24 tick) external {\n        bytes32 stateSlot = keccak256(abi.encodePacked(PoolId.unwrap(id), bytes32(uint256(6))));\n        slots[stateSlot] = bytes32((uint256(uint24(tick)) << 160) | (1 << 96));\n    }\n\n    function extsload(bytes32 slot) external view returns (bytes32) {\n        return slots[slot];\n    }\n\n    function exttload(bytes32) external pure returns (bytes32) {\n        return bytes32(0); // never unlocked, nothing synced\n    }\n}\n\ncontract Reserve is ERC20 {\n    constructor() ERC20(\"ARB\", \"ARB\") {}\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\ncontract InstallWindowProofTest is Test {\n    using PoolIdLibrary for PoolKey;\n\n    PoolManagerStub pm;\n    FeeVault vault;\n    Token token;\n    Reserve arb;\n    PoolKey key;\n\n    function setUp() public {\n        pm = new PoolManagerStub();\n        vault = new FeeVault(address(this));\n        arb = new Reserve();\n        // Token must sort below ARB to be currency0: redeploy until it does.\n        token = new Token(\"T\", \"T\", 1e27, address(0xBEEF));\n        while (address(token) > address(arb)) token = new Token(\"T\", \"T\", 1e27, address(0xBEEF));\n        key = PoolKey({\n            currency0: Currency.wrap(address(token)),\n            currency1: Currency.wrap(address(arb)),\n            fee: 3000,\n            tickSpacing: 60,\n            hooks: IHooks(address(0))\n        });\n        pm.setTick(key.toId(), 0);\n        vm.warp(1_000_000);\n        vm.roll(100);\n    }\n\n    /// Fees that arrive after the strategy is deployed but before it is installed are not \"fees\n    /// that arrive after it is installed\": they are banked, yet the strategy splits and spends them.\n    function test_feesBetweenDeployAndInstallAreCounted() public {\n        arb.mint(address(vault), 100e18);\n        vault.sync(address(arb));\n        address[] memory ex = new address[](0);\n        DrawdownBurnStrategy s = new DrawdownBurnStrategy(\n            IPoolManager(address(pm)), vault, key, token, address(arb), 0, ex, 1e18, 600, address(this)\n        );\n        assertEq(s.accounted(), 100e18);\n\n        // Swaps keep happening; the hook banks 7 ARB in the vault while no strategy is installed.\n        arb.mint(address(vault), 7e18);\n        vault.sync(address(arb));\n\n        // Install.\n        vault.setStrategy(s);\n        vault.transferOwnership(address(s));\n\n        vault.onFeeReceived(address(0));\n        // Expected: nothing to spend, the 7 ARB was banked before installation.\n        assertEq(s.pot() + s.pendingWall(), 0, \"pre-install fees were split as spendable\");\n    }\n}","reproduction":"Vault holds 100 ARB (synced). Deploy strategy: accounted = 100. Before setStrategy, swaps bank 7 ARB (vault.totalReceived = 107). Install, then any observation. Expected: pot + pendingWall = 0 (the 7 ARB arrived before installation). Actual: 7 ARB split as spendable; _due() will spend it. Proof test attached (fails now).","severity":"low","snippet":"        accounted = vault_.totalReceived(reserve_);","title":"Fees received between deployment and installation are counted as spendable, contrary to 'spends only fees that arrive after it is installed'"},{"citation":"resolved","description":"onFeeReceived treats any nonzero token balance as 'bought tokens waiting to be burned' and returns after burning instead of running _due()/buyInside. The balance is externally writable: anyone can transfer 1 wei of the token to the strategy. Each such transfer (a ~60k-gas checkpointing transfer, well under a cent on Arbitrum) converts the next trader's swap from a buyback into a dust burn, so a griefer who keeps a 1-wei balance present (one transfer per L1 block is enough since the buyback is once per block.number) suppresses every in-swap buyback; the pot accumulates until someone pays for buyback(). Combined with the gas finding this is the second cheap way to disable the on-trade automation.\n\nFix: track the bought amount in storage (set in _buy, cleared in _burnHeld) and branch on that rather than on balanceOf; burn any surplus balance opportunistically but do not let it pre-empt the buyback.","line":250,"path":"src/DrawdownBurnStrategy.sol","reproduction":"State: pot = 5 ARB, no tokens held. Griefer transfers 1 wei token to the strategy, then a trader sells. Expected: BoughtBack emitted inside the sell. Actual: Burned(1) emitted and onFeeReceived returns at line 252; no buyback. Repeat per block.","severity":"low","snippet":"        if (token.balanceOf(address(this)) != 0) {","title":"Any 1-wei token transfer to the strategy turns the next swap's in-swap buyback into a burn-and-return"},{"citation":"resolved","description":"ERC20Checkpoints.sol notes that block.number on Arbitrum is the L1 block number and uses timestamps instead; the strategy uses block.number for the anchor, the tier and lastBuy. The effect is that one 'block' here spans all L2 blocks sharing an L1 block number (typically ~12 s, longer during L1 delays): the first swap in that window anchors the tier and buyback limit for every later swap in it, and at most one buyback runs per ~12 s rather than per 250 ms L2 block. This is stricter rate limiting than the NatSpec describes but also means a dump early in a 12 s window keeps the tier and limit for all trades in that window. If the per-L1-block behaviour is intended, document it; if per-L2-block was intended, key on block.timestamp or ArbSys.arbBlockNumber().","line":392,"path":"src/DrawdownBurnStrategy.sol","reproduction":"On Arbitrum One, two swaps 3 seconds apart with the same L1 block number: the second sees anchor.block_ == block.number and gets no new anchor and no buyback (lastBuy == block.number). Expected per NatSpec ('once a block'): a new anchor and buyback in a new L2 block. Actual: shared with the earlier swap.","severity":"info","snippet":"        if (a.block_ != block.number) {","title":"'Once per block' and the anchor step are keyed on block.number, which on Arbitrum One is the L1 block number (~12 s)"},{"citation":"resolved","description":"The strategy must own the vault to spend (vault.withdraw is onlyOwner) and must be vault.strategy to be notified, but it exposes no wrapper for vault.setStrategy(). If the vault's ownership is transferred to the strategy before setStrategy(this) is called, the only way out is releaseVault() (hand the vault to the owner, call setStrategy, transfer back), and in the meantime fees arrive unobserved (see the install-window finding). Also, while the strategy is set but not yet owner, every in-swap buyInside reverts inside the catch and only the split lands. Adding an owner-only `setVaultStrategy(IFeeStrategy)` passthrough (and an explicit install step that re-baselines accounted) removes the trap.","line":671,"path":"src/DrawdownBurnStrategy.sol","reproduction":"Owner calls vault.transferOwnership(strategy) first, then tries vault.setStrategy(strategy): reverts OwnableUnauthorizedAccount. The strategy has no function to make that call; recovery requires releaseVault().","severity":"info","snippet":"        vault.transferOwnership(owner());","title":"No way for the strategy to install itself as the vault's strategy; installation order is a one-way trap"}],"hash":"bb8e48139bb24749b209a9abdfe5cb4c5d34a6e4806792409ea8fc771c0c4606","nodeId":"01dfc94a-d2e3-48ad-99fb-9a7094fd89ed","outcome":"completed","summary":"The review is complete and the findings file is written and valid.\n\n**Outcome.** The core accounting (pot, pendingWall, accounted versus the vault's real ARB) and the v4 flash accounting in every unlockCallback path hold up: `_toVault` correctly re-baselines returned ARB, every path settles to zero deltas, the synced-currency guard and the reentrancy guard close the re-entry routes, and the bank is not spendable by any permissionless path. The defects found are in the surrounding mechanics, not in a direct theft of the bank.\n\n**Findings written to `.imd-findings.json` (7 total, 2 with Foundry proofs):**\n\n- **Medium, anchor and ATH walk (line 382).** A single held pump plus free per-block observations through `buyback()` or the vault's unguarded `onFeeReceived` steps the anchor to the pumped tick and makes the hour's close the new ATH. The NatSpec's claim that this costs a fee round trip per block is wrong. There is no owner function to lower `athMcap`, so an inflated ATH permanently distorts tiers and can place the wall above the real price so `buildWall` never lays it. Demonstrated with a stub test.\n- **Medium, in-swap buyback DoS via initialized ticks (line 257).** Measured on a real local PoolManager: 139k gas baseline versus 368k with seven dust-initialized ticks in the path, about 33k each. Two or three dust positions inside the 200-tick buyback path push `buyInside` past the 400k cap on every swap, permanently, for a one-time cost.\n- **Low, `withdrawFromVault` does not observe first (line 657).** Fees the vault has recorded but the strategy has not split are invisible to the reconciliation, so pot plus pendingWall can exceed the vault balance and `buildWall` can revert. Proof test attached and confirmed failing.\n- **Low, deploy-to-install window (line 222).** `accounted` is baselined in the constructor, so fees banked before `setStrategy` and ownership transfer are split as spendable. Proof test attached and confirmed failing.\n- **Low, 1-wei token transfer blocks the next in-swap buyback (line 250).**\n- **Info, `block.number` is the L1 block on Arbitrum (line 392)**, so \"once per block\" means once per ~12 seconds.\n- **Info, no `setStrategy` passthrough (line 671)**, making installation order a one-way trap.\n\n**Not found.** No pre-existing-balance spend by a non-owner, no unsettled delta path, no exploitable sync/settle clobbering of an outer swap, no wall tick-math error, and no way to make `buildWall` revert other than the overcount in the low finding.\n\n**Scratch material** lives under `test/scratch/` only: three failing or demonstrating tests, the gas measurement, and the script that assembled the JSON. Nothing under `src/`, `lib/`, or configuration was touched.","treeHash":null,"usage":{"cachedInputTokens":1677920,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":75732,"runtime":"claude","turns":29,"wallClockMs":1096011}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"99e4819277ea59fc","findings":[],"hash":"d8a9544435951a4b4288a15c829660110734aee1cea269a773f723d9a87d4f6f","nodeId":"8f86cb0f-ffa9-4c90-9b51-ee332d8aa582","outcome":"failed","summary":"submodules did not fetch: Submodule 'lib/forge-std' (https://github.com/foundry-rs/forge-std) registered for path 'lib/forge-std'\nSubmodule 'lib/openzeppelin-contracts' (https://github.com/OpenZeppelin/openzeppelin-contracts) registered for path 'lib/openzeppelin-contracts'\nSubmodule 'lib/v4-core' (https://github.com/Uniswap/v4-core) registered for path 'lib/v4-core'\nSubmodule 'lib/v4-periphery' (https://github.com/Uniswap","treeHash":null,"usage":{"cachedInputTokens":0,"inputTokens":0,"model":null,"outputTokens":0,"runtime":"claude","turns":0,"wallClockMs":24036}}],"verification":[]}