{"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":"930ea061-8714-4c90-9af4-892a151310cb","kind":"audit","nodes":[{"acceptedSubmissionHash":"0d34695605f4c57f906ed923404848c345858a5f01ca3a0c5fbede96d72f8ffb","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":"b0fbc9d468443e35be6d1184a86b3835c5d4a1677593798acacbdcc1f7de4d75","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":"95139b8ed4a140fefb281fd4b54b49c6d33d6ccc26efcc0b861508fa224e6e2e","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":"c7d5674022f30cb8c5a24697592cb8248b6db70d1fe83ed44788dfb9bc0ff5b2","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":"720faec0b12ce56eb2ef687f67096c6fb5e88c45607f6c5e02686f3db300c194","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":"X: https://x.com/imdplace\nv3.2, follow-up to audit 85083c05. Full adversarial review before the token launch.\n\n1. Build and test under both profiles: the protected foundry.toml (via_ir = true) and the launch profile (FOUNDRY_VIA_IR=false FOUNDRY_BYTECODE_HASH=none, optimizer 200 runs, solc 0.8.26, cancun). Confirm GACHA_RUNTIME_CODE_HASH 0x2db1143638494d63cd5b4b807bd3dfe333d590ea58e7204ab7fd0c5cf415c4cd matches BagGacha runtime code under the launch profile (testPinnedGachaRuntimeCodeHash).\n2. Verify every 85083c05 fix: bindGacha preconditions against the deployed GoblinNFT (launch-1616: targetsSet, reserveTarget == vrfTarget == gacha, polTarget == gacha.polManager(), 7-day review ended, genesis().gacha()/oracle match); POL positive deltas; afterSwap PartialFill incl. exact-output rule realised == amount + fee; recorded liquidity min(current L, liquidityAt(30m)) and the genesisEthHeld bound; tokensForEth(GOLD) Math.min; genesisValueAndCap at the 4h TWAP; MIN_OUT_BPS 9475; exact-output gross fee; cancelRequest after 24h and its interaction with Curator pauses.\n3. Hunt new issues from the changes: oracle/TWAP manipulation at minimum depth (MIN_LIQUIDITY 5 ETH, MIN_GENESIS_DEPTH 1 ETH, PAYOUT_CAP_BPS 500), payout above paid value, stuck funds, hook reentrancy, genesis lane griefing, VRF request/cancel races.\n4. Check the manifest remainder path (paying wallet vs $contract:GoldHook) is valid for IMD.\n\nReport any medium or higher with a concrete fix. NO SOURCE CHANGES.","parentJobId":null,"planHash":"cebd1b0953e50b2016ffd7bdf3b20441eddfb269f78e1d96d3f887a3e503a20f","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"930ea061-8714-4c90-9af4-892a151310cb","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":"51482","feedbackHash":"378e912ccb4a2c8633661d186bc973de575d2b8c90a43dc5030bc7b5bd24b11e","nodeKey":"audit_economics","submissionHash":"0d34695605f4c57f906ed923404848c345858a5f01ca3a0c5fbede96d72f8ffb","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51166","feedbackHash":"94a621b82840daae6db628338bfa21071fc8d91bfa134517bf8d72bced2dda10","nodeKey":"audit_flow","submissionHash":"b0fbc9d468443e35be6d1184a86b3835c5d4a1677593798acacbdcc1f7de4d75","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51487","feedbackHash":"aa832222439e599188c1e4c0b2d3dbaad8e301825f7a9dc338e044848a5996fd","nodeKey":"audit_judge","submissionHash":"95139b8ed4a140fefb281fd4b54b49c6d33d6ccc26efcc0b861508fa224e6e2e","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51043","feedbackHash":"4ba39d4cc4e498cb1e485278ac58082389a495532ed726ed930a2661215ee841","nodeKey":"audit_math","submissionHash":"c7d5674022f30cb8c5a24697592cb8248b6db70d1fe83ed44788dfb9bc0ff5b2","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52268","feedbackHash":"c8f121e51cc49901d77ae3eb8e08a0c433d6706dd245550bd28d2c3e6158a006","nodeKey":"audit_permissions","submissionHash":"720faec0b12ce56eb2ef687f67096c6fb5e88c45607f6c5e02686f3db300c194","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"977105b402c7bd650cea6bcc06a17cd601cc0b0866d9d249c4393fe41f52959f","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"dedd4428da8fa41e","findings":[{"citation":"resolved","description":"The v3.2 fix bounds the liquidity term by min(current L, liquidityAt(30m)), but ethSide = L*2^96/sqrtP (bounded to tick 887220) still uses the current sqrtPrice read from goldHook.slot0() at line 228. ETH is currency0, so an exact-input ETH->GOLD swap lowers sqrtP and raises L/sqrtP by about the ETH it puts in. The docstring and report say 'liquidity added inside one transaction moves neither the depth bound nor the payout cap'; moving the price does the same thing. Impact is limited: the Genesis buyback is still at most (1-d)P and POLManager's TWAP minOut still holds. What gets bypassed is the thin-pool gate whose purpose is to stop GOLD from being priced off a TWAP on a pool too shallow to resist manipulation.","line":236,"path":"src/TwapOracle.sol","reproduction":"State: GoldHook pool with recorded L such that genesisEthHeld(L, sqrtP) = 0.5 ETH (TwapOracle.genesisValueAndCap reverts ThinPool, so the Genesis lane is off). In one transaction: swap about 0.6 ETH exact-input ETH->GOLD (two 0.3 ETH swaps in the first hour because of the buy cap; about 0.585 ETH reaches the pool after the 1% hook fee and 1.25% LP fee). L is unchanged, sqrtP falls, genesisEthHeld is about 1.08 ETH >= MIN_GENESIS_DEPTH, and genesisValueAndCap/tokensForEth(GOLD) return values. In the same transaction BagGacha.spin() commits a Genesis quote; afterwards the attacker sells back. Expected: still ThinPool. Actual: priced. Fix: compute the depth at a price the transaction cannot move, e.g. sqrtPriceAtTick(shortTick) (the 30-minute TWAP tick), or take the max of the spot and TWAP sqrtPrice so the ETH-side figure can only go down.","severity":"low","snippet":"        ethSide = genesisEthHeld(liquidity, sqrtPrice);","title":"Genesis depth gate and payout cap use the spot sqrtPrice, so ETH swapped in within the same transaction lifts a thin pool over MIN_GENESIS_DEPTH"},{"citation":"resolved","description":"After CANCEL_DELAY, cancelRequest and rawFulfillRandomWords race each other. The wrapper's fulfilment transaction carries the word in public calldata, so the spinner can work out the lane and Bag that draw() would pick from the committed snapshot (snapIds/req.odds are public through snapshot()/currentRequest()) before it lands. If the outcome is bad, the spinner front-runs with cancelRequest, gets P back as a credit and spins again. This is only reachable when VRF is more than 24 h late, so it is low.","line":668,"path":"src/BagGacha.sol","reproduction":"Spinner calls spin() at t0 and VRF stalls. At t0+24h+1 the wrapper's fulfilment with word W is in the mempool; the spinner evaluates keccak(W,'lane') and _select(W) off-chain, sees the Genesis lane or a tier-0 Bag, and sends cancelRequest() with higher priority. Expected: an outcome cannot be refused once it is known. Actual: credits[spinner] += P, the fulfilment is ignored and the spinner re-spins. Fix: allow cancellation only by someone other than the spinner, or forfeit req.paid to the reserve on cancel (instead of refunding it) when cancelled by the spinner.","severity":"low","snippet":"        if (block.timestamp < uint256(req.requestedAt) + CANCEL_DELAY) revert CancelNotDue();","title":"cancelRequest can front-run a late VRF fulfilment whose random word is already visible, giving the spinner a free re-roll"},{"citation":"resolved","description":"README launch 2 says 'vault, gacha and POLManager must share $contract:TwapOracle: bindGacha checks every one of these'. bindGacha only checks that vault.oracle == gacha.oracle. It never checks that the oracle's genesisToken()/goldHook() equal this hook's token/address, or that POLManager's oracle/token/hook match. Separately, GoblinNFT.setLaunchTargets can be called again after bindGacha (it only requires !released), so the permanently bound gacha can end up different from the escrow targets. Both depend on the NFT owner and the 7-day holder review (a trust assumption), so this is info.","line":300,"path":"src/GoldHook.sol","reproduction":"The owner names gacha G whose oracle is an arbitrary contract O (O.genesisToken() is not GOLD), with vault.oracle = O. After 7 days bindGacha(G) succeeds although the README says the oracle is checked. Or: bind G1, then call setLaunchTargets(G2...), and release() sends the escrow to G2 while hook fees keep going to G1. Fix: also require TwapOracle(gacha.oracle()).goldHook() == address(this) && genesisToken() == token, and POLManager.oracle()/hook()/token() to match; or correct the README claim.","severity":"info","snippet":"        if (IHookGenesisVault(vault).oracle() != IHookGacha(gacha_).oracle()) revert GachaMismatch();","title":"bindGacha does not check the oracle's or POLManager's wiring, and the NFT owner can retarget after binding"}],"hash":"0d34695605f4c57f906ed923404848c345858a5f01ca3a0c5fbede96d72f8ffb","nodeId":"10767088-a6b1-46cd-892d-b9bb0fa4a872","outcome":"completed","summary":"I found nothing at medium severity or higher, so there are no medium+ fixes to report. There are two low findings and one informational note, all in `.imd-findings.json` (valid JSON). No source files were changed.\n\n**1. Builds and the pinned hash**\n- **Protected profile** (`via_ir = true`): 160 of 160 tests pass.\n- **Launch profile** (`FOUNDRY_VIA_IR=false FOUNDRY_BYTECODE_HASH=none`, 200 runs, solc 0.8.26, cancun): 160 of 160 pass, including `testPinnedGachaRuntimeCodeHash`, so `0x2db1…c4cd` matches the BagGacha runtime code under that profile.\n- I left out the fork tests in `test/fork/` in both runs.\n\n**2. The 85083c05 fixes**\nI read each one through against the code and they hold:\n- **`bindGacha` preconditions:** targets set, reserve and VRF target both equal the gacha, POL target equals `gacha.polManager()`, the 7-day review has ended, and the vault's token, gacha and oracle match.\n- **POL positive deltas:** settled or taken on both sides.\n- **`PartialFill`:** buys expect `amount − fee − tax`; exact-output sells expect `amount + fee`.\n- **Recorded liquidity:** `min(current L, liquidityAt(30m))`, with the `genesisEthHeld` bound.\n- **Quotes and fees:** `tokensForEth(GOLD)` takes `Math.min`; `genesisValueAndCap` uses only the 4-hour TWAP; `MIN_OUT_BPS` is 9475; the exact-output fee is taken on the gross amount.\n- **`cancelRequest`:** it makes no oracle calls, so a Curator pause cannot block cancel or draw.\n\n**3. New issues**\n- **Low — the 1 ETH depth gate can be passed with ETH added in the same transaction** (`src/TwapOracle.sol:236`). The depth figure still uses the current pool price, so swapping about 0.6 ETH in lifts a 0.5 ETH pool over `MIN_GENESIS_DEPTH` and GOLD gets priced. This contradicts the v3.2 claim that nothing done inside one transaction moves the bound. The damage is limited: the Genesis buyback stays at or below what the spin paid, and the POL swaps keep their TWAP floor. The fix is to compute the depth at the TWAP price, or at whichever of spot and TWAP gives the lower ETH figure.\n- **Low — a spinner can refuse a known result** (`src/BagGacha.sol:668`). When VRF is more than 24 hours late, the random word is visible in the fulfilment transaction before it lands. The spinner can work out the outcome, cancel ahead of it, get the 0.01 ETH back and spin again. Fixes: stop the spinner from cancelling their own request, or forfeit the spin price to the reserve when they do.\n- **Info — the README says more is checked at binding than the code checks.** `bindGacha` does not check the oracle's GOLD token and hook, or the POLManager's oracle, token and hook. The NFT owner can also re-name the launch targets after binding, which would split the escrow and the hook fees between two gachas. Both depend on the owner and the 7-day holder review, so I've treated this as a trust assumption.\n\nI checked the rest of item 3 (payout above the paid value, stuck funds, hook reentrancy, griefing the Genesis lane) and found nothing to report. I did not write any reproduction tests, since none of the findings is high enough to need one.\n\n**4. Remainder path**\nBoth options work in the code. With option B (the hook holds the leftover GOLD), the only way out is `bindGacha`, which sends it all to the bound GenesisVault. Extra GOLD sent to the hook is harmless. Whether IMD's manifest accepts `$contract:GoldHook` as the remainder recipient can't be settled from this repository, because there's no manifest specification in it. That needs confirming with IMD.","treeHash":null,"usage":{"cachedInputTokens":840331,"inputTokens":24,"model":"claude-opus-5-5","outputTokens":13277,"runtime":"claude","turns":15,"wallClockMs":598894}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"ff35db055462d2ad","findings":[{"citation":"resolved","description":"`_checkGenesisPool` takes `min(current L, liquidityAt(30 min))` and converts it to ETH with `genesisEthHeld(L, sqrtP)`, which integrates L from the current price up to tick 887220, i.e. it assumes every unit of active liquidity is a full-range position. Uniswap v4 active liquidity is the sum of all positions whose range contains the current tick, and a position one or two tick spacings wide carries roughly 1/(1-sqrt(1.0001^-120)) ~ 330x the liquidity per ETH of a full-range position. Anyone can add such a position through the PoolManager (the hook has no add-liquidity permission), trigger one swap in the next block so an observation records it, and wait 30 minutes: both the JIT bound of finding 4 and the `genesisEthHeld` bound of finding 4 are satisfied, yet the reported ETH-side depth is hundreds of times the ETH the pool holds. Consequences: (1) the 5% payout cap (`valueAndCap(GOLD)`, `genesisValueAndCap`) that v3.1/v3.2 rely on to bound what a manipulated GOLD TWAP can extract per draw is lifted from 5% of the real depth to the uncapped tier maximum (0.7 ETH per Bag, 5% of reserve), so the TWAP-manipulation economics the audit accepted (cap 0.05 ETH at 1 ETH depth) no longer hold: with ~2 ETH of launch depth an attacker who moves both TWAP windows and seals up to 32 GOLD Bags can be paid 95%/88% of inflated values bounded only by the reserve; (2) `MIN_GENESIS_DEPTH` (1 ETH) is bypassed with ~0.03 ETH, so the Genesis lane and POL buys open on a pool whose TWAP costs almost nothing to move. Regular v3 pools are protected from the same trick because `_liquidBalances` bounds the depth by the pool's real WETH balance (line 265); the genesis pool has no such bound since the v4 PoolManager is a singleton holding every pool's ETH. The capital is parked, not spent: the position is withdrawn after the draw. Fix (minimal, preserves the design): make active liquidity equal full-range liquidity by giving GoldHook the BEFORE_ADD_LIQUIDITY permission (flags 0x30CC -> 0x38CC, re-mine the address) and reverting in `beforeAddLiquidity` unless `params.tickLower == -887220 && params.tickUpper == 887220`; POLManager and the launch seed already use that range, so nothing else changes and all existing depth math becomes exact. Alternative without a flag change: have the oracle read, via `extsload`, the liquidity of the full-range positions of the known owners (POLManager at (-887220, 887220, salt 0) and the launch seeding contract) from `pools[id].positions[...]` and use min(that, liquidityAt(30 min)) as the depth, ignoring everything else.","line":236,"path":"src/TwapOracle.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 \"test/v4-core/PoolManager.sol\";\nimport {IPoolManager} from \"test/v4-core/interfaces/IPoolManager.sol\";\nimport {IHooks} from \"test/v4-core/interfaces/IHooks.sol\";\nimport {Currency} from \"test/v4-core/types/Currency.sol\";\nimport {PoolKey as CoreKey} from \"test/v4-core/types/PoolKey.sol\";\nimport {SwapParams as CoreSwap, ModifyLiquidityParams as CoreModify} from \"test/v4-core/types/PoolOperation.sol\";\nimport {PoolSwapTest} from \"test/v4-core/test/PoolSwapTest.sol\";\nimport {PoolModifyLiquidityTest} from \"test/v4-core/test/PoolModifyLiquidityTest.sol\";\nimport {GoldHook} from \"src/GoldHook.sol\";\nimport {TwapOracle} from \"src/TwapOracle.sol\";\n\ncontract ScratchGold is ERC20 {\n    constructor() ERC20(\"GOLD\", \"GOLD\") {}\n\n    function mint(address to, uint256 a) external {\n        _mint(to, a);\n    }\n}\n\ncontract ScratchV3Factory {\n    function getPool(address, address, uint24) external pure returns (address) {\n        return address(0);\n    }\n}\n\n/// @notice The genesis depth (`TwapOracle._checkGenesisPool` / `genesisEthHeld`) converts the pool's *active*\n///         liquidity into ETH as if every unit of it were a full-range position. A narrow position (one tick spacing\n///         wide) carries roughly 330x the liquidity per ETH of a full-range one, so 1 ETH parked in a 120-tick range\n///         for 30 minutes makes the oracle report hundreds of ETH of depth and lifts the 5% payout cap accordingly.\n///         The same trick makes a pool below MIN_GENESIS_DEPTH priceable.\ncontract ConcentratedDepthTest is Test {\n    uint160 constant SQRT_PRICE_1_1 = 79228162514264337593543950336;\n    address constant HOOK_ADDRESS = address((uint160(0x4A) << 152) | 0x30CC);\n    address factoryEOA = address(0xFAC7);\n    address attacker = address(0xA77);\n\n    PoolManager manager;\n    ScratchGold gold;\n    GoldHook hook;\n    TwapOracle oracle;\n    PoolSwapTest swapRouter;\n    PoolModifyLiquidityTest lpRouter;\n    CoreKey key;\n\n    receive() external payable {}\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        manager = new PoolManager(address(this));\n        gold = new ScratchGold();\n        GoldHook impl = new GoldHook(address(manager), address(gold), factoryEOA);\n        vm.etch(HOOK_ADDRESS, address(impl).code);\n        hook = GoldHook(payable(HOOK_ADDRESS));\n        swapRouter = new PoolSwapTest(IPoolManager(address(manager)));\n        lpRouter = new PoolModifyLiquidityTest(IPoolManager(address(manager)));\n        key = CoreKey(Currency.wrap(address(0)), Currency.wrap(address(gold)), 12500, 60, IHooks(HOOK_ADDRESS));\n        oracle = new TwapOracle(\n            address(this), address(0x1111), address(new ScratchV3Factory()), address(gold), HOOK_ADDRESS\n        );\n        vm.deal(address(this), 10_000 ether);\n        vm.deal(attacker, 10 ether);\n        vm.prank(factoryEOA);\n        manager.initialize(key, SQRT_PRICE_1_1);\n    }\n\n    /// @dev Returns false when the PoolManager (hook) refuses the position: a fix that only admits full-range\n    ///      positions makes the attacker's add revert, which is the fixed behaviour.\n    function _addLiquidity(address who, int24 lower, int24 upper, int256 liquidity, uint256 eth, uint256 tokens)\n        internal\n        returns (bool ok)\n    {\n        gold.mint(who, tokens);\n        vm.startPrank(who);\n        gold.approve(address(lpRouter), tokens);\n        try lpRouter.modifyLiquidity{value: eth}(key, CoreModify(lower, upper, liquidity, bytes32(0)), \"\") {\n            ok = true;\n        } catch {\n            ok = false;\n        }\n        vm.stopPrank();\n    }\n\n    function _tinySwap(bool zeroForOne) internal {\n        uint160 limit = zeroForOne ? 4295128739 + 1 : 1461446703485210103287273052203988822378723970342 - 1;\n        if (!zeroForOne) {\n            gold.mint(address(this), 1 ether);\n            gold.approve(address(swapRouter), type(uint256).max);\n        }\n        swapRouter.swap{value: zeroForOne ? 0.0001 ether : 0}(\n            key, CoreSwap(zeroForOne, -0.0001 ether, limit), PoolSwapTest.TestSettings(false, false), \"\"\n        );\n    }\n\n    /// @dev Honest launch: 2 ETH + 2 GOLD of full-range liquidity (L = 2e18 at 1:1), history older than 4 hours.\n    function _honestPool() internal {\n        assertTrue(_addLiquidity(address(this), -887220, 887220, 2e18, 2 ether + 1, 2 ether + 1));\n        vm.warp(vm.getBlockTimestamp() + 1 hours);\n        _tinySwap(true);\n        vm.warp(vm.getBlockTimestamp() + 4 hours);\n        _tinySwap(false);\n    }\n\n    function testNarrowPositionInflatesGenesisDepthAndPayoutCap() public {\n        _honestPool();\n        uint256 honestCap = oracle.payoutCap(address(gold));\n        assertApproxEqRel(honestCap, 0.1 ether, 0.02e18, \"5% of 2 ETH of full-range depth\");\n\n        // Attacker: ~1 ETH + ~1 GOLD in the 120-tick range around the price, liquidity 333e18 (vs 2e18 honest).\n        (, int24 tick) = hook.slot0();\n        int24 lower = (tick / 60) * 60 - 60;\n        if (!_addLiquidity(attacker, lower, lower + 120, 333e18, 1.5 ether, 1.5 ether)) return; // narrow add refused: fixed\n        uint256 attackerEthSpent = 10 ether - attacker.balance;\n        assertLt(attackerEthSpent, 1.1 ether, \"about one ETH of capital\");\n\n        // a swap in the next block records the liquidity; 30 minutes later the oracle counts it\n        vm.warp(vm.getBlockTimestamp() + 1);\n        _tinySwap(true);\n        vm.warp(vm.getBlockTimestamp() + 30 minutes);\n        _tinySwap(false);\n\n        uint256 cap = oracle.payoutCap(address(gold));\n        (, uint256 genesisCap) = oracle.genesisValueAndCap(1e18);\n        emit log_named_decimal_uint(\"honest cap (ETH)\", honestCap, 18);\n        emit log_named_decimal_uint(\"attacker ETH spent\", attackerEthSpent, 18);\n        emit log_named_decimal_uint(\"cap after narrow position (ETH)\", cap, 18);\n        // Expected: the pool now holds about 3.5 ETH, so the cap should be at most 5% of that.\n        // Actual: the oracle reports ~335 ETH of depth and a cap of ~16 ETH, ~150x the honest cap.\n        assertLe(cap, (3.5 ether * oracle.PAYOUT_CAP_BPS()) / 10_000 + 0.01 ether, \"cap bounded by the ETH the pool holds\");\n        assertLe(genesisCap, (3.5 ether * oracle.PAYOUT_CAP_BPS()) / 10_000 + 0.01 ether, \"genesis cap too\");\n    }\n\n    function testNarrowPositionMakesAPoolBelowMinimumDepthPriceable() public {\n        // only 0.5 ETH of full-range depth: below MIN_GENESIS_DEPTH, GOLD must stay unpriceable\n        assertTrue(_addLiquidity(address(this), -887220, 887220, 0.5e18, 0.5 ether + 1, 0.5 ether + 1));\n        vm.warp(vm.getBlockTimestamp() + 1 hours);\n        _tinySwap(true);\n        vm.warp(vm.getBlockTimestamp() + 4 hours);\n        _tinySwap(false);\n        vm.expectRevert(abi.encodeWithSelector(TwapOracle.ThinPool.selector, HOOK_ADDRESS));\n        oracle.valueInEth(address(gold), 1e18);\n\n        // 0.02 ETH in a 120-tick range is enough liquidity to clear the 1 ETH bound\n        (, int24 tick) = hook.slot0();\n        int24 lower = (tick / 60) * 60 - 60;\n        if (!_addLiquidity(attacker, lower, lower + 120, 5e18, 0.03 ether, 0.03 ether)) return; // refused: fixed\n        vm.warp(vm.getBlockTimestamp() + 1);\n        _tinySwap(true);\n        vm.warp(vm.getBlockTimestamp() + 30 minutes);\n        _tinySwap(false);\n        // Expected: still ThinPool (the pool holds ~0.55 ETH). Actual: priced, depth reported above 1 ETH.\n        vm.expectRevert(abi.encodeWithSelector(TwapOracle.ThinPool.selector, HOOK_ADDRESS));\n        oracle.valueInEth(address(gold), 1e18);\n    }\n}","reproduction":"On the real vendored PoolManager (test/scratch/ConcentratedDepth.t.sol): seed 2 ETH + 2 GOLD full range (L = 2e18), write 4 h of history; `oracle.payoutCap(GOLD)` = 0.0999 ETH. Attacker adds L = 333e18 in [tick-60, tick+60] spending 0.997 ETH (+ ~1 GOLD), swaps 0.0001 ETH in the next block, waits 30 min, swaps once more. Expected: cap <= 5% of the ~3.5 ETH the pool now holds (<= 0.185 ETH). Actual: `payoutCap(GOLD)` = 16.7499 ETH and `genesisValueAndCap` cap = 16.7499 ETH (reported depth ~335 ETH). Second test: 0.5 ETH full-range seed reverts `ThinPool` as designed; after the attacker adds L = 5e18 in a 120-tick range for ~0.03 ETH and waits 30 min, `valueInEth(GOLD, 1e18)` no longer reverts (depth reported > 1 ETH). Run: `FOUNDRY_VIA_IR=false FOUNDRY_BYTECODE_HASH=none forge test --match-path test/scratch/ConcentratedDepth.t.sol -vv` -> both tests fail on current code.","severity":"high","snippet":"        ethSide = genesisEthHeld(liquidity, sqrtPrice);","title":"Genesis depth and payout cap count narrow-range liquidity as if it were full-range: ~1 ETH in a 120-tick position inflates the GOLD cap 167x and bypasses MIN_GENESIS_DEPTH"},{"citation":"resolved","description":"Once 24 h have passed without fulfilment, `cancelRequest()` (anyone) and `rawFulfillRandomWords` (the wrapper) race. The wrapper's fulfilment transaction carries the random word in calldata, so the spinner can read it in the mempool, compute `keccak256(abi.encode(word, \"lane\"))` and `_select`, and front-run with `cancelRequest()` when the result is a cheap Bag (getting `P` back as credit) while letting a jackpot word land and calling `draw()`. The VRF fee is the only cost of the option. Fix: once the request is cancellable, no fulfilment may count any more: in `rawFulfillRandomWords` add `|| block.timestamp >= uint256(req.requestedAt) + CANCEL_DELAY` to the ignore condition (so after 24 h the only outcome is cancellation), or alternatively make `cancelRequest` check `block.timestamp >= requestedAt + CANCEL_DELAY` and additionally require that the wrapper reports the request as not fulfilled.","line":668,"path":"src/BagGacha.sol","reproduction":"State: `pending`, `req.fulfilled == false`, `block.timestamp == req.requestedAt + 24 h`. The wrapper's `rawFulfillRandomWords(id, [w])` is in the mempool with a `w` whose lane/tier selects a 0.3P Bag. The spinner submits `cancelRequest()` with a higher priority fee. Expected: the committed draw resolves with the word the VRF produced. Actual: cancel executes first (`pending = false`, `vrfRequestId = 0`, `credits[spinner] += P`), the word is ignored on arrival, and the spinner repeats the spin. With a jackpot word the spinner instead lets the fulfilment land and draws.","severity":"low","snippet":"        if (block.timestamp < uint256(req.requestedAt) + CANCEL_DELAY) revert CancelNotDue();","title":"Late VRF word after CANCEL_DELAY gives the spinner a free abort option: cancel a visible losing word, let a winning one land"},{"citation":"resolved","description":"Item 4 of the brief: the repository carries no IMD manifest grammar, so whether `remainderTo = $contract:GoldHook` is accepted cannot be verified here; the README already marks it conditional. What can be verified: under path B the only exit for GOLD held by the hook is a successful `bindGacha`, which needs the deployed GoblinNFT's owner to name a set whose wiring passes every check (`targetsSet`, reserve == vrf == gacha, pol == gacha.polManager(), 7-day review, nft/token/vault/oracle match). If the owner never names it, names it with one wrong word, or launch 2 deploys with a BagGacha whose runtime hash differs from the pinned one (any compiler-setting drift), the remainder is unrecoverable: the hook has no owner, no sweep and no second bind. Under path A the same failure leaves the GOLD in the paying wallet, recoverable by a redeploy. Recommendation: launch with path A unless IMD confirms `$contract:` references are valid in `remainderTo` and the launch-2 artefacts have been hash-checked against the pinned hook constant on the final bytecode; additionally note that the NFT owner can call `setLaunchTargets` again after `bindGacha`, so the mint escrow (`release`) can go to a different set than the hook's reserve share (deployed NFT behaviour, a documented trust assumption, not a code defect in this tree).","line":304,"path":"src/GoldHook.sol","reproduction":"Not a code defect; operational. State: launch 1 with `remainderTo = $contract:GoldHook`, then `setLaunchTargets` never called or called with `polTarget != BagGacha.polManager()`. Expected per README: GOLD reaches GenesisVault at bind. Actual: `bindGacha` reverts `NotLaunchTarget` forever and `IERC20(GOLD).balanceOf(hook)` stays at the full remainder with no function able to move it.","severity":"info","snippet":"        if (held > 0) IERC20(token).safeTransfer(vault, held);","title":"Manifest remainder path B parks the whole Genesis supply behind a bind that may never succeed; path A is the safe default"}],"hash":"720faec0b12ce56eb2ef687f67096c6fb5e88c45607f6c5e02686f3db300c194","nodeId":"04bc9030-3705-4f37-88be-93a2c0e68814","outcome":"completed","summary":"Review complete. The findings file holds three entries, and no source file was changed. The only files written are `.imd-findings.json` and the scratch proof under `test/scratch/` (which git ignores).\n\n**Verification of the brief (items 1 and 2)**\n\n- Both profiles build and pass the full suite: the protected `via_ir = true` profile (whole tree, 160 tests) and the launch profile with `FOUNDRY_VIA_IR=false FOUNDRY_BYTECODE_HASH=none` (160 tests). The v3.2 report could not complete the via_ir run; this machine did.\n- `testPinnedGachaRuntimeCodeHash` passes under the launch profile, so the pinned hash matches the v3.2 BagGacha runtime code.\n- Every 85083c05 fix traced as described: the bindGacha preconditions match the deployed GoblinNFT exactly (targetsSet, reserve == vrf == gacha, pol == gacha.polManager(), 7-day review constant, wiring checks); POL positive deltas are taken and both currencies settle to zero; the partial-fill rule is correct against v4 semantics, since afterSwap receives the raw pool delta and the exact-output sell is handed amount + fee; recorded liquidity, the genesisEthHeld bound, the Math.min genesis quote, the 4h valuation, MIN_OUT_BPS 9475, the gross exact-output fee and the 24h cancel path all behave as documented, and cancel does not touch the oracle so Curator pauses cannot block it.\n\n**Findings**\n\n1. **High, `src/TwapOracle.sol:236`.** The genesis depth converts the pool's active liquidity to ETH as if every unit were full-range. A 120-tick position carries ~330x the liquidity per ETH, and the hook has no add-liquidity permission, so anyone can add one, trigger a swap in the next block, wait 30 minutes and have the oracle count it. On the real PoolManager, about 1 ETH parked this way lifted the GOLD payout cap from 0.1 ETH to 16.75 ETH, and 0.03 ETH made a 0.5 ETH pool pass the 1 ETH minimum. This defeats the cap that bounds TWAP-manipulation extraction per draw. Regular v3 pools are protected by the WETH-balance bound; the singleton v4 pool is not. Fix: give GoldHook the before-add-liquidity permission (flags 0x30CC to 0x38CC) and refuse any range other than (-887220, 887220), or read only the known full-range positions via extsload. Proof attached: `test/scratch/ConcentratedDepth.t.sol`, two tests that fail on current code under both profiles.\n2. **Low, `src/BagGacha.sol:668`.** After 24 hours, a spinner can read a late VRF word in the mempool and front-run with cancelRequest when it loses, or let it land when it wins. Fix: ignore any fulfilment arriving once the request is cancellable.\n3. **Info, `src/GoldHook.sol:304`.** Manifest item 4: no IMD manifest grammar is in the tree, so path B's validity cannot be verified here. Path B parks the entire Genesis supply behind a bind that has no fallback if the NFT owner never names a matching set or the launch-2 bytecode hash drifts. Path A is the recoverable default.\n\n**Not found after checking:** hook reentrancy (every unlock callback is caller-bound and flush resets its flag before external calls), payout above paid value on the Genesis lane (88% of at most 0.0092 ETH), stuck funds beyond the documented pre-bind reserve share, genesis lane griefing beyond the documented liveness conditions, and VRF races other than the one reported.","treeHash":null,"usage":{"cachedInputTokens":1842581,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":41060,"runtime":"claude","turns":31,"wallClockMs":941702}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"9e51ef2afd7c2af8","findings":[{"citation":"resolved","description":"_checkGenesisPool bounds the liquidity term by min(current L, liquidityAt(30 min)) and converts it to ETH with genesisEthHeld(L, sqrtP) = L*2^96/sqrtP (integrated to tick 887220), i.e. the ETH a FULL-RANGE position of liquidity L holds. Uniswap v4 active liquidity is the sum of every position whose range contains the current tick, and GoldHook has no add-liquidity permission, so anyone can add a position one or two tick spacings wide through the PoolManager. Such a position carries about 1/(1-sqrt(1.0001^-120)) ~ 330x the liquidity per ETH of a full-range position while holding ~0.3% of the ETH the formula credits it with. The v3.2 recorded-liquidity rule only delays the attacker by one block plus 30 minutes (one swap in the next block records the new L; the position must still be active 30 minutes later, which the attacker ensures by keeping the price inside the range). Consequences: (1) PAYOUT_CAP_BPS (5% of ETH-side depth), the per-draw bound v3.1/v3.2 rely on against a manipulated GOLD TWAP (valueAndCap(GOLD) and genesisValueAndCap), is loosened to the tier maximum / 5% of the reserve with ~1 ETH of parked, fully recoverable capital; (2) MIN_GENESIS_DEPTH (1 ETH) is passed with ~0.03 ETH, so the Genesis lane and POL buys open on a pool whose TWAP costs almost nothing to move. Regular v3 pools are at least bounded by the pool's WETH balance (_liquidBalances); the v4 PoolManager is a singleton so no such bound exists for GOLD. Merged from two specialist reports of the same defect (audit_math, audit_permissions). Fix (keeps the design): make active liquidity equal full-range liquidity by giving GoldHook the BEFORE_ADD_LIQUIDITY permission (flags 0x30CC -> 0x38CC, re-mine the address) and reverting in beforeAddLiquidity unless params.tickLower == -887220 && params.tickUpper == 887220 (POLManager and the launch seed already use that range, so all existing depth math becomes exact). Alternative without a flag change: have TwapOracle read, via extsload of pools[id].positions[keccak256(abi.encodePacked(owner, int24(-887220), int24(887220), bytes32(0)))], the liquidity of the full-range positions of the trusted owners (POLManager and the launch seed holder) and use min(that, current L, liquidityAt(30 min)) in genesisEthHeld.","line":236,"path":"src/TwapOracle.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 \"test/v4-core/PoolManager.sol\";\nimport {IPoolManager} from \"test/v4-core/interfaces/IPoolManager.sol\";\nimport {IHooks} from \"test/v4-core/interfaces/IHooks.sol\";\nimport {Currency} from \"test/v4-core/types/Currency.sol\";\nimport {PoolKey as CoreKey} from \"test/v4-core/types/PoolKey.sol\";\nimport {SwapParams as CoreSwap, ModifyLiquidityParams as CoreModify} from \"test/v4-core/types/PoolOperation.sol\";\nimport {PoolSwapTest} from \"test/v4-core/test/PoolSwapTest.sol\";\nimport {PoolModifyLiquidityTest} from \"test/v4-core/test/PoolModifyLiquidityTest.sol\";\nimport {GoldHook} from \"src/GoldHook.sol\";\nimport {TwapOracle} from \"src/TwapOracle.sol\";\n\ncontract ScratchGold is ERC20 {\n    constructor() ERC20(\"GOLD\", \"GOLD\") {}\n\n    function mint(address to, uint256 a) external {\n        _mint(to, a);\n    }\n}\n\ncontract ScratchV3Factory {\n    function getPool(address, address, uint24) external pure returns (address) {\n        return address(0);\n    }\n}\n\n/// @notice The genesis depth (`TwapOracle._checkGenesisPool` / `genesisEthHeld`) converts the pool's *active*\n///         liquidity into ETH as if every unit of it were a full-range position. A narrow position (one tick spacing\n///         wide) carries roughly 330x the liquidity per ETH of a full-range one, so 1 ETH parked in a 120-tick range\n///         for 30 minutes makes the oracle report hundreds of ETH of depth and lifts the 5% payout cap accordingly.\n///         The same trick makes a pool below MIN_GENESIS_DEPTH priceable.\ncontract ConcentratedDepthTest is Test {\n    uint160 constant SQRT_PRICE_1_1 = 79228162514264337593543950336;\n    address constant HOOK_ADDRESS = address((uint160(0x4A) << 152) | 0x30CC);\n    address factoryEOA = address(0xFAC7);\n    address attacker = address(0xA77);\n\n    PoolManager manager;\n    ScratchGold gold;\n    GoldHook hook;\n    TwapOracle oracle;\n    PoolSwapTest swapRouter;\n    PoolModifyLiquidityTest lpRouter;\n    CoreKey key;\n\n    receive() external payable {}\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        manager = new PoolManager(address(this));\n        gold = new ScratchGold();\n        GoldHook impl = new GoldHook(address(manager), address(gold), factoryEOA);\n        vm.etch(HOOK_ADDRESS, address(impl).code);\n        hook = GoldHook(payable(HOOK_ADDRESS));\n        swapRouter = new PoolSwapTest(IPoolManager(address(manager)));\n        lpRouter = new PoolModifyLiquidityTest(IPoolManager(address(manager)));\n        key = CoreKey(Currency.wrap(address(0)), Currency.wrap(address(gold)), 12500, 60, IHooks(HOOK_ADDRESS));\n        oracle = new TwapOracle(\n            address(this), address(0x1111), address(new ScratchV3Factory()), address(gold), HOOK_ADDRESS\n        );\n        vm.deal(address(this), 10_000 ether);\n        vm.deal(attacker, 10 ether);\n        vm.prank(factoryEOA);\n        manager.initialize(key, SQRT_PRICE_1_1);\n    }\n\n    /// @dev Returns false when the PoolManager (hook) refuses the position: a fix that only admits full-range\n    ///      positions makes the attacker's add revert, which is the fixed behaviour.\n    function _addLiquidity(address who, int24 lower, int24 upper, int256 liquidity, uint256 eth, uint256 tokens)\n        internal\n        returns (bool ok)\n    {\n        gold.mint(who, tokens);\n        vm.startPrank(who);\n        gold.approve(address(lpRouter), tokens);\n        try lpRouter.modifyLiquidity{value: eth}(key, CoreModify(lower, upper, liquidity, bytes32(0)), \"\") {\n            ok = true;\n        } catch {\n            ok = false;\n        }\n        vm.stopPrank();\n    }\n\n    function _tinySwap(bool zeroForOne) internal {\n        uint160 limit = zeroForOne ? 4295128739 + 1 : 1461446703485210103287273052203988822378723970342 - 1;\n        if (!zeroForOne) {\n            gold.mint(address(this), 1 ether);\n            gold.approve(address(swapRouter), type(uint256).max);\n        }\n        swapRouter.swap{value: zeroForOne ? 0.0001 ether : 0}(\n            key, CoreSwap(zeroForOne, -0.0001 ether, limit), PoolSwapTest.TestSettings(false, false), \"\"\n        );\n    }\n\n    /// @dev Honest launch: 2 ETH + 2 GOLD of full-range liquidity (L = 2e18 at 1:1), history older than 4 hours.\n    function _honestPool() internal {\n        assertTrue(_addLiquidity(address(this), -887220, 887220, 2e18, 2 ether + 1, 2 ether + 1));\n        vm.warp(vm.getBlockTimestamp() + 1 hours);\n        _tinySwap(true);\n        vm.warp(vm.getBlockTimestamp() + 4 hours);\n        _tinySwap(false);\n    }\n\n    function testNarrowPositionInflatesGenesisDepthAndPayoutCap() public {\n        _honestPool();\n        uint256 honestCap = oracle.payoutCap(address(gold));\n        assertApproxEqRel(honestCap, 0.1 ether, 0.02e18, \"5% of 2 ETH of full-range depth\");\n\n        // Attacker: ~1 ETH + ~1 GOLD in the 120-tick range around the price, liquidity 333e18 (vs 2e18 honest).\n        (, int24 tick) = hook.slot0();\n        int24 lower = (tick / 60) * 60 - 60;\n        if (!_addLiquidity(attacker, lower, lower + 120, 333e18, 1.5 ether, 1.5 ether)) return; // narrow add refused: fixed\n        uint256 attackerEthSpent = 10 ether - attacker.balance;\n        assertLt(attackerEthSpent, 1.1 ether, \"about one ETH of capital\");\n\n        // a swap in the next block records the liquidity; 30 minutes later the oracle counts it\n        vm.warp(vm.getBlockTimestamp() + 1);\n        _tinySwap(true);\n        vm.warp(vm.getBlockTimestamp() + 30 minutes);\n        _tinySwap(false);\n\n        uint256 cap = oracle.payoutCap(address(gold));\n        (, uint256 genesisCap) = oracle.genesisValueAndCap(1e18);\n        emit log_named_decimal_uint(\"honest cap (ETH)\", honestCap, 18);\n        emit log_named_decimal_uint(\"attacker ETH spent\", attackerEthSpent, 18);\n        emit log_named_decimal_uint(\"cap after narrow position (ETH)\", cap, 18);\n        // Expected: the pool now holds about 3.5 ETH, so the cap should be at most 5% of that.\n        // Actual: the oracle reports ~335 ETH of depth and a cap of ~16 ETH, ~150x the honest cap.\n        assertLe(cap, (3.5 ether * oracle.PAYOUT_CAP_BPS()) / 10_000 + 0.01 ether, \"cap bounded by the ETH the pool holds\");\n        assertLe(genesisCap, (3.5 ether * oracle.PAYOUT_CAP_BPS()) / 10_000 + 0.01 ether, \"genesis cap too\");\n    }\n\n    function testNarrowPositionMakesAPoolBelowMinimumDepthPriceable() public {\n        // only 0.5 ETH of full-range depth: below MIN_GENESIS_DEPTH, GOLD must stay unpriceable\n        assertTrue(_addLiquidity(address(this), -887220, 887220, 0.5e18, 0.5 ether + 1, 0.5 ether + 1));\n        vm.warp(vm.getBlockTimestamp() + 1 hours);\n        _tinySwap(true);\n        vm.warp(vm.getBlockTimestamp() + 4 hours);\n        _tinySwap(false);\n        vm.expectRevert(abi.encodeWithSelector(TwapOracle.ThinPool.selector, HOOK_ADDRESS));\n        oracle.valueInEth(address(gold), 1e18);\n\n        // 0.02 ETH in a 120-tick range is enough liquidity to clear the 1 ETH bound\n        (, int24 tick) = hook.slot0();\n        int24 lower = (tick / 60) * 60 - 60;\n        if (!_addLiquidity(attacker, lower, lower + 120, 5e18, 0.03 ether, 0.03 ether)) return; // refused: fixed\n        vm.warp(vm.getBlockTimestamp() + 1);\n        _tinySwap(true);\n        vm.warp(vm.getBlockTimestamp() + 30 minutes);\n        _tinySwap(false);\n        // Expected: still ThinPool (the pool holds ~0.55 ETH). Actual: priced, depth reported above 1 ETH.\n        vm.expectRevert(abi.encodeWithSelector(TwapOracle.ThinPool.selector, HOOK_ADDRESS));\n        oracle.valueInEth(address(gold), 1e18);\n    }\n}","reproduction":"Real vendored PoolManager, GoldHook etched at a 0x30CC-flagged address, real TwapOracle(goldHook). Seed 2 ETH + 2 GOLD full range (L = 2e18 at 1:1), write 4 h of ring history with two tiny swaps: oracle.payoutCap(GOLD) = 0.0999 ETH. Attacker adds liquidity 333e18 in [tick-60, tick+60] via PoolModifyLiquidityTest, spending 0.997 ETH (+ ~1 GOLD); swaps 0.0001 ETH in the next block; warps 30 minutes; swaps once more. Expected: cap <= 5% of the ~3.5 ETH the pool now holds (<= 0.185 ETH). Actual: payoutCap(GOLD) = 16.7499 ETH and genesisValueAndCap cap = 16.7499 ETH (reported depth ~335 ETH; attacker's funds remain withdrawable). Second test: a 0.5 ETH full-range seed reverts ThinPool as designed; after the attacker adds L = 5e18 in a 120-tick range for ~0.03 ETH and waits 30 minutes, valueInEth(GOLD, 1e18) no longer reverts. Run: FOUNDRY_VIA_IR=false FOUNDRY_BYTECODE_HASH=none forge test --match-path test/scratch/ConcentratedDepthB.t.sol -vv -> both tests fail on v3.2 (verified in this review under both profiles; a second specialist proof with a 100 ETH pool gives cap 21.65 ETH vs 5.05 ETH real).","severity":"high","snippet":"        ethSide = genesisEthHeld(liquidity, sqrtPrice);","title":"Genesis depth and payout cap treat concentrated liquidity as full-range: a narrow in-range position inflates both ~330x per ETH and bypasses MIN_GENESIS_DEPTH"},{"citation":"resolved","description":"v3.1 TwapOracle.tokensForEth(GOLD) returned the larger token amount of the 30-minute and 4-hour windows, which is the protective direction for a buyer's minimum-output floor (a pump of one window leaves the floor at the unpumped window and the buy reverts). The v3.2 fix for finding 5 changed it to Math.min (src/TwapOracle.sol:176-177) for the GenesisVault quote, where min is right (the vault sells GOLD). POLManager._minOut still derives its floor from the same function, and for a buyer the smaller token quote is the LESS protective one: when the 30-minute TWAP is pumped above the 4-hour TWAP the floor follows the pumped window and the permissionless buyAndBurn / addLiquidity (1 ETH / 2 ETH per call, repeatable until the budget is spent) execute at the pumped price with only the 5.25% allowance against the pumped mark. README states MIN_OUT_BPS makes these revert on 'a manipulated pool'; that now only holds for a pump shorter than 30 minutes. An attacker holding GOLD pumps for 31 minutes, then loops buyAndBurn/addLiquidity and sells into the POL's buys; the POL budgets (50% of the NFT escrow, hook reserve shares, spin POL shares) are spent at an inflated price. Fix: keep min for the vault but give POLManager a floor that follows the larger of the two window quotes, e.g. add TwapOracle.polTokensForEth(uint256 ethAmount) returning Math.max(short, long) for GOLD (the v3.1 behaviour) and call it from _minOut; or compute _minOut from the 4-hour tick of genesisValueAndCap.","line":122,"path":"src/POLManager.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {Math} from \"@openzeppelin/contracts/utils/math/Math.sol\";\nimport {PoolManager} from \"test/v4-core/PoolManager.sol\";\nimport {IPoolManager} from \"test/v4-core/interfaces/IPoolManager.sol\";\nimport {IHooks} from \"test/v4-core/interfaces/IHooks.sol\";\nimport {Currency} from \"test/v4-core/types/Currency.sol\";\nimport {PoolKey as CoreKey} from \"test/v4-core/types/PoolKey.sol\";\nimport {SwapParams as CoreSwap, ModifyLiquidityParams as CoreModify} from \"test/v4-core/types/PoolOperation.sol\";\nimport {PoolSwapTest} from \"test/v4-core/test/PoolSwapTest.sol\";\nimport {PoolModifyLiquidityTest} from \"test/v4-core/test/PoolModifyLiquidityTest.sol\";\nimport {GoldHook} from \"src/GoldHook.sol\";\nimport {TwapOracle} from \"src/TwapOracle.sol\";\nimport {POLManager} from \"src/POLManager.sol\";\nimport {TickMath} from \"src/TickMath.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\n\ncontract ScratchGold is ERC20 {\n    constructor() ERC20(\"GOLD\", \"GOLD\") {}\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\ncontract ScratchV3Factory {\n    function getPool(address, address, uint24) external pure returns (address) {\n        return address(0);\n    }\n}\n\n/// @notice v3.2 finding 5 changed `TwapOracle.tokensForEth(GOLD)` from the larger token amount of the two windows to\n///         the smaller one, for the GenesisVault quote. POLManager uses the same function as its slippage floor\n///         (`minOut = tokensForEth(GOLD, ethIn) * 9475 / 10000`), where the protective direction is the opposite:\n///         the floor must follow the window that gives MORE tokens, otherwise a 30-minute pump of the GOLD price\n///         lowers the floor and the permissionless `buyAndBurn` / `addLiquidity` buy GOLD at the pumped price.\n///         This test fails on v3.2 (the buy goes through at ~60% of the 4-hour TWAP token amount) and passes once\n///         POLManager's floor again follows the larger of the two window quotes.\ncontract PolFloorInvertedTest is Test {\n    uint160 constant SQRT_PRICE_1_1 = 79228162514264337593543950336;\n    address constant HOOK_ADDRESS = address((uint160(0x4A) << 152) | 0x30CC);\n    address factoryEOA = address(0xFAC7);\n    address trader = address(0x7A4D);\n\n    PoolManager manager;\n    ScratchGold gold;\n    GoldHook hook;\n    PoolSwapTest swapRouter;\n    PoolModifyLiquidityTest lpRouter;\n    CoreKey key;\n    TwapOracle oracle;\n    POLManager pol;\n\n    receive() external payable {}\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        manager = new PoolManager(address(this));\n        gold = new ScratchGold();\n        GoldHook impl = new GoldHook(address(manager), address(gold), factoryEOA);\n        vm.etch(HOOK_ADDRESS, address(impl).code);\n        hook = GoldHook(payable(HOOK_ADDRESS));\n        swapRouter = new PoolSwapTest(IPoolManager(address(manager)));\n        lpRouter = new PoolModifyLiquidityTest(IPoolManager(address(manager)));\n        key = CoreKey(Currency.wrap(address(0)), Currency.wrap(address(gold)), 12500, 60, IHooks(HOOK_ADDRESS));\n        vm.deal(trader, 1000 ether);\n        vm.deal(address(this), 10_000 ether);\n        vm.prank(factoryEOA);\n        manager.initialize(key, SQRT_PRICE_1_1);\n        // seed 100 ETH / 100 GOLD full range (L = 100e18), as the launch would\n        gold.mint(address(this), 100 ether);\n        gold.approve(address(lpRouter), 100 ether);\n        lpRouter.modifyLiquidity{value: 100 ether}(key, CoreModify(-887220, 887220, 100e18, bytes32(0)), \"\");\n\n        oracle = new TwapOracle(address(this), address(0xBEEF), address(new ScratchV3Factory()), address(gold), HOOK_ADDRESS);\n        pol = new POLManager(address(manager), address(gold), HOOK_ADDRESS, address(oracle), address(0xEEEE));\n    }\n\n    function _swap(bool zeroForOne, int256 amountSpecified) internal {\n        uint160 limit = zeroForOne ? 4295128739 + 1 : 1461446703485210103287273052203988822378723970342 - 1;\n        uint256 value;\n        if (zeroForOne) {\n            value = uint256(-amountSpecified);\n        } else {\n            gold.mint(trader, 1_000_000 ether);\n            vm.prank(trader);\n            gold.approve(address(swapRouter), type(uint256).max);\n        }\n        vm.prank(trader);\n        swapRouter.swap{value: value}(\n            key, CoreSwap(zeroForOne, amountSpecified, limit), PoolSwapTest.TestSettings(false, false), \"\"\n        );\n    }\n\n    function _tokensAtTick(int24 tick, uint256 ethAmount) internal pure returns (uint256) {\n        uint160 sqrtRatio = TickMath.sqrtPriceAtTick(tick);\n        return Math.mulDiv(uint256(sqrtRatio) * sqrtRatio, ethAmount, 1 << 192);\n    }\n\n    function _meanTicks() internal view returns (int24 shortTick, int24 longTick) {\n        uint32[] memory agos = new uint32[](3);\n        agos[0] = 30 minutes;\n        agos[1] = 4 hours;\n        int56[] memory c = hook.observe(agos);\n        shortTick = int24((c[2] - c[0]) / int56(uint56(30 minutes)));\n        longTick = int24((c[2] - c[1]) / int56(uint56(4 hours)));\n    }\n\n    function testThirtyMinutePumpLowersThePolFloorAndTheBurnBuysAtThePumpedPrice() public {\n        // GOLD priceable: a swap past the tax window records the seeded liquidity, 4 hours of ring history\n        vm.warp(vm.getBlockTimestamp() + 1 hours);\n        _swap(true, -0.001 ether);\n        vm.warp(vm.getBlockTimestamp() + 4 hours);\n        _swap(false, -0.001 ether);\n        uint256 fair = oracle.tokensForEth(address(gold), 0.1 ether);\n        assertApproxEqRel(fair, 0.1 ether, 0.01e18, \"1:1 pool: 0.1 GOLD per 0.1 ETH\");\n\n        // attacker pumps GOLD: 30 ETH into a 100 ETH-deep pool, held for 31 minutes\n        _swap(true, -30 ether);\n        vm.warp(vm.getBlockTimestamp() + 31 minutes);\n        (int24 s, int24 l) = _meanTicks();\n        uint256 shortQuote = _tokensAtTick(s, 0.1 ether);\n        uint256 longQuote = _tokensAtTick(l, 0.1 ether);\n        assertLt(shortQuote, (longQuote * 70) / 100, \"the 30-minute window is at the pumped price\");\n        assertGt(longQuote, (fair * 90) / 100, \"the 4-hour window barely moved\");\n\n        // v3.2: the POL floor now follows the pumped window (fewer tokens), not the 4-hour one\n        uint256 floorNow = (oracle.tokensForEth(address(gold), 0.1 ether) * pol.MIN_OUT_BPS()) / 10_000;\n        uint256 floorProtective = (longQuote * pol.MIN_OUT_BPS()) / 10_000;\n        emit log_named_uint(\"floor used by POLManager (GOLD per 0.1 ETH)\", floorNow);\n        emit log_named_uint(\"floor at the 4-hour TWAP               \", floorProtective);\n\n        // a permissionless burn of POL money must NOT clear at the pumped price: the pool pays far less than the\n        // 4-hour quote minus the 5.25% allowance, which is what MIN_OUT_BPS is documented to guarantee\n        pol.depositBurn{value: 1 ether}();\n        vm.expectRevert(POLManager.Slippage.selector);\n        pol.buyAndBurn(0.1 ether);\n    }\n}","reproduction":"Real vendored PoolManager, GoldHook at a flagged address, pool seeded 100 ETH / 100 GOLD (L = 100e18), real TwapOracle(goldHook) and POLManager(oracle). 1) warp 1 h, swap 0.001 ETH; warp 4 h, swap 0.001 GOLD: tokensForEth(GOLD, 0.1 ETH) ~ 0.1 GOLD. 2) Attacker buys with 30 ETH exact input, warp 31 minutes: 30-minute mean-tick quote = 0.0598 GOLD per 0.1 ETH, 4-hour quote = 0.0936. 3) pol.depositBurn{value: 1 ether}(); pol.buyAndBurn(0.1 ether). Expected (README, v3.1): revert Slippage because the pool pays < 4-hour quote * 0.9475 = 0.0887 GOLD. Actual on v3.2: minOut = 0.0566 GOLD and the burn executes at the pumped price (~35% fewer tokens than the 4-hour TWAP). Run: FOUNDRY_VIA_IR=false FOUNDRY_BYTECODE_HASH=none forge test --match-path test/scratch/PolFloorInverted.t.sol -> fails 'next call did not revert as expected' (verified under both profiles); passes with the GOLD branch of the POL floor restored to Math.max.","severity":"high","snippet":"        return (oracle.tokensForEth(token, ethIn) * MIN_OUT_BPS) / 10_000;","title":"POLManager slippage floor inverted by the finding-5 change: tokensForEth(GOLD) now returns the smaller window quote, so a 30-minute pump lets buyAndBurn/addLiquidity buy GOLD at the pumped price"},{"citation":"resolved","description":"The v3.2 fix for audit finding 4 bounds only the genesis (v4) pool by min(current L, liquidityAt(30 min)). _liquidBalances, which feeds valueAndCap/payoutCap and the MIN_LIQUIDITY check for every regular token, still uses pool.liquidity() and weth.balanceOf(pool) at call time. Two mechanisms follow, both in this function. (a) JIT: the cap is read in BagGacha._buildSnapshot during the spin request, so a depositor can in one transaction flash-loan WETH+token, mint a narrow v3 position around the current tick (liquidity() rises by orders of magnitude, the WETH balance by the deposit), call spin() so the snapshot commits uncapped amounts, then burn the position. A 0.7 ETH Bag of a token whose pool holds the 5 ETH minimum is then paid in one draw (0.665 ETH on keep) instead of being split at the 0.25 ETH cap, removing the per-draw bound the cap was introduced for. (b) Parked balances: wethSide = min(balanceOf(pool), virtual) counts WETH held by positions entirely out of range (and plain transfers), which cannot absorb a sale at the current price, so MIN_LIQUIDITY and a 0.25 ETH cap can be met persistently by ~5 WETH parked far above the price plus a one-spacing-wide in-range position of a few hundredths of an ETH. Merged from audit_flow (medium, JIT) and audit_math (low, out-of-range). Fix: mirror the genesis bound. v3 observe() also returns secondsPerLiquidityCumulativeX128; derive the harmonic-mean active liquidity over the 30-minute window and use min(current liquidity, 30-minute mean liquidity) (extend IUniswapV3PoolOracle.observe to decode the second array), and bound the ETH side by the amount the active liquidity actually holds within a fixed band around the price (e.g. up to the next initialized tick or +-5%) rather than the raw WETH balance.","line":261,"path":"src/TwapOracle.sol","reproduction":"test/scratch/V3JitCap.t.sol (mock v3 pool with the same interface the oracle reads): TwapOracle with pool(token, weth) listed via setPool; pool holds 5 WETH / 5 TOK with liquidity 5e18 -> payoutCap(token) = 0.25 ETH. In the same block and transaction mint 95 WETH + 95 TOK to the pool and set liquidity to 100e18 -> payoutCap(token) = 5 ETH. Expected (the finding-4 property the genesis pool now has, testJitPayoutCap): the cap does not move inside one transaction. Actual: forge test --match-path test/scratch/V3JitCap.t.sol fails with '5000000000000000000 != 250000000000000000'. For (b): a listed pool with 5 WETH in an out-of-range position above the price, 5 ETH of TOKEN below it, and an in-range one-spacing position holding 0.02 WETH whose L*2^96/sqrtP >= 5e18 passes validatePool (wethSide = min(5e18, virtual) = 5e18) instead of reverting ThinPool.","severity":"medium","snippet":"        uint128 liquidity = IUniswapV3PoolOracle(pool).liquidity();","title":"Finding-4 fix incomplete for regular (v3) tokens: the payout cap is read from the pool's current liquidity and raw WETH balance, so it is raised inside one transaction (JIT) and satisfiable with out-o"},{"citation":"resolved","description":"ethSide = genesisEthHeld(min(L, liquidityAt(30m)), sqrtPrice) uses the current price from goldHook.slot0(). ETH is currency0, so buying GOLD lowers sqrtP and raises L*2^96/sqrtP by roughly the ETH put in. A price move changes no L, so the v3.2 recorded-liquidity bound does nothing against it. Two consequences. (1) 4-hour pump at minimum depth (the brief's 'TWAP manipulation at minimum depth' case): GOLD is sealable in regular Bags (isPriced(GOLD) is true once the pool is initialized) and is valued at min(30m, 4h) TWAP. With the pool at the 1 ETH minimum, pumping GOLD 10x costs about 2.16 ETH of recoverable ETH held in the pool for 4 hours plus ~0.1 ETH of fees; after 4 hours both windows are ~10x and the cap is 5% of genesisEthHeld(L, sqrtP/3.16) = 0.158 ETH instead of 0.05 ETH. A Bag of 0.03 GOLD sealed 24 h earlier (tier 1) is snapshotted at 0.3 ETH, capped at 0.158 ETH and split; a keep pays the depositor 95% = 0.150 ETH from the reserve for tokens worth 0.016 ETH pre-pump, and the spinner (same attacker) gets the tokens back. _splitBag leaves the remainder listed with its original listedAt, so it is drawable again on the next spin at the same pumped mark; the attacker loops spin -> draw -> keep while the pump lasts, each win paying up to min(cap, 5% of reserve). The Curator's only answer (pause GOLD) needs a 1-day IMD vote. (2) Same-transaction gate bypass: with recorded depth 0.5 ETH (ThinPool), two exact-input ETH->GOLD swaps of 0.3 ETH in one transaction move sqrtP so that genesisEthHeld ~ 1.08 ETH >= MIN_GENESIS_DEPTH, and BagGacha.spin() in the same transaction commits a Genesis quote; the attacker sells back afterwards. Merged from audit_flow (medium) and audit_economics (low). Fix: value the genesis depth and cap at a price the transaction cannot move and the pump must sustain beyond the pricing window, e.g. genesisEthHeld(L, max(sqrtPrice, sqrtPriceAtTick(longTick))) so the ETH-side figure can only go down, and bound L by liquidityAt(LONG_WINDOW) as well; and/or exclude the genesis token from seal() (keep it to the Genesis lane, whose value is bounded by (1-d)P), or raise MIN_GENESIS_DEPTH to a level at which a 4-hour pump costs more than 5% of the reserve. Independent of the concentrated-liquidity finding (different mechanism and fix); fixing one does not fix the other.","line":228,"path":"src/TwapOracle.sol","reproduction":"State: GOLD/ETH pool with recorded L giving genesisEthHeld = 1 ETH at price 1 (MIN_GENESIS_DEPTH), reserve 10 ETH, GenesisVault unfunded (regular lane 100%). Attacker seals Bag{GOLD, 0.03e18} (value 0.03 ETH, tier 1, below 5% of reserve) at T0. At T0+24h buys GOLD with 2.16 ETH (price x10, sqrtP / 3.16), waits 4 h with one tiny swap per hour (staleness). At T0+28h: valueInEth(GOLD, 0.03e18) ~ 0.3 ETH, payoutCap(GOLD) = 500/10000 * genesisEthHeld(L, sqrtP/3.16) ~ 0.158 ETH. spin(): bagPayout caps to 0.158 ETH and records the split; VRF word; draw() -> _splitBag; choose(keep) -> credits[attacker] += 0.150 ETH, reserve -= 0.150 ETH. Expected: the cap does not grow with the manipulator's own pump (0.05 ETH at 1 ETH depth). Actual: 0.158 ETH, repeatable each spin while the pump lasts. Gate case: pool with recorded depth 0.5 ETH, genesisValueAndCap reverts ThinPool; in one transaction swap 0.6 ETH exact-input ETH->GOLD (two 0.3 ETH swaps inside the buy cap), L unchanged, genesisEthHeld ~ 1.08 ETH, genesisValueAndCap/tokensForEth(GOLD) return values and spin() commits a Genesis quote. Expected: still ThinPool. Actual: priced.","severity":"medium","snippet":"        (uint160 sqrtPrice,) = goldHook.slot0();","title":"Genesis depth and payout cap are valued at the spot sqrtPrice: a GOLD pump raises the cap by sqrt(pump) and an in-transaction swap lifts a thin pool over MIN_GENESIS_DEPTH"},{"citation":"resolved","description":"bindGacha accepts only the set named through the deployed GoblinNFT's setLaunchTargets and then still requires the pinned runtime code hash and the vault/oracle wiring. The deployed GoblinNFT (src/GoblinNFT.sol:203-204, 221-230, runtime verified against the deployed address by the project's own tests) lets anyone call release() the moment the collection is sold out and the 7-day review has ended, and setLaunchTargets reverts AlreadyReleased afterwards. The README runbook orders bindGacha before release, but release is permissionless, so the order is not enforceable. If the named set fails the hook's own checks, the naming can never be corrected after release and bindGacha reverts forever. The most likely trigger is in the repository itself: the protected foundry.toml has via_ir = true while GACHA_RUNTIME_CODE_HASH is pinned for via_ir = false, bytecode_hash = none; a launch-2 BagGacha built with the repository profile has runtime hash 0x9796d137...dd361b and fails CodeMismatch. Consequences: reserveAccrued (half of every 1% hook fee plus all 90/50/25% launch taxes, held as currency-0 ERC6909 claims in the PoolManager) is never flushable and the hook has no other exit for it (no owner, no sweep, no second bind); under path B the GOLD launch remainder is stuck as well, the GenesisVault is never funded and the Genesis lane never opens. The gacha still receives the NFT escrow through pullMintEscrow, so the failure is silent. Fix (keeps the finding-1 design): (1) add a hook view bindCheck(address) returns (bytes4 reason) evaluating every bindGacha precondition except the review timer, and have the runbook require it to return zero (plus an eth_call of bindGacha after the review) before setLaunchTargets and before anyone can release; (2) pin the launch-2 build profile in the runbook/manifest and verify keccak256(extcodecopy(BagGacha)) == GACHA_RUNTIME_CODE_HASH on chain before naming; (3) optionally let bindGacha accept the named reserve/VRF target after nft.released() when only the code-hash check fails, since the owner naming plus holder review is then the authority the design relies on (document the trade-off).","line":289,"path":"src/GoldHook.sol","reproduction":"State: NFT owner calls setLaunchTargets(polManager, gachaX, gachaX) where gachaX is a BagGacha whose extcodehash != 0x2db1...c4cd (any BagGacha built with the repository's foundry.toml via_ir = true: forge test --match-test testPinnedGachaRuntimeCodeHash -vv without FOUNDRY_VIA_IR=false prints 0x9796d1370679550f382780faab25b5d150ed263364ab04c5178a2c3741dd361b, verified in this review, versus the pinned 0x2db1143638494d63cd5b4b807bd3dfe333d590ea58e7204ab7fd0c5cf415c4cd), or whose GenesisVault.oracle() != gachaX.oracle(). Collection sells out, 7 days pass, anyone calls GoblinNFT.release(). Then GoldHook.bindGacha(gachaX) reverts CodeMismatch/GachaMismatch; GoblinNFT.setLaunchTargets(...) reverts AlreadyReleased; every later GoldHook.flush() pays holders only and reserveAccrued keeps growing with no function able to move it; IERC20(GOLD).balanceOf(hook) (path B) is immovable. Expected: a recoverable naming mistake, as the 7-day review is documented to provide. Actual: permanent loss of the reserve share and (path B) the genesis supply.","severity":"medium","snippet":"        if (gacha_.codehash != gachaRuntimeCodeHash()) revert CodeMismatch();","title":"bindGacha can be locked out forever: GoblinNFT.release() freezes the named targets, so a named set that fails any bindGacha check strands reserveAccrued (and, under path B, the whole GOLD remainder)"},{"citation":"resolved","description":"After CANCEL_DELAY, cancelRequest() (anyone, full refund of P as credit, earmark released) and the wrapper's rawFulfillRandomWords are both valid in the same block. The fulfilment transaction carries the random word in public calldata and draw() is deterministic from it (lane via keccak(word,'lane'), tier and Bag via _select over the public snapshot()/currentRequest()). A spinner who sees a losing word pending cancels first (pending = false, vrfRequestId = 0, credits[spinner] += P), the word is ignored on arrival (id mismatch) and the spinner re-spins; a winning word is left to land. A third party can likewise cancel a visible winning fulfilment to grief the spinner. The VRF fee is the only cost of the option. Reachable only when a Chainlink fulfilment is more than 24 h late, hence low; it is the only ordering race the new cancel path introduces. Merged from three identical specialist reports. Fix: once the request is cancellable no fulfilment may count any more: in rawFulfillRandomWords add '|| block.timestamp >= uint256(req.requestedAt) + CANCEL_DELAY' to the ignore condition (after 24 h the only outcome is cancellation); alternatively make cancel two-step (announce, take effect a block later) or refund P minus a penalty so the option has a cost.","line":668,"path":"src/BagGacha.sol","reproduction":"State: pending == true, req.fulfilled == false, block.timestamp == req.requestedAt + 24 h. The wrapper's rawFulfillRandomWords(id, [w]) is in the mempool with a w whose lane/tier selects a 0.3P Bag. The spinner submits cancelRequest() with a higher priority fee. Expected: the committed draw resolves with the word the VRF produced. Actual: cancel executes first (pending = false, vrfRequestId = 0, credits[spinner] += P), rawFulfillRandomWords returns early on the id mismatch (line 655), and the spinner spins again. With a jackpot word the spinner instead lets the fulfilment land and calls draw().","severity":"low","snippet":"        if (block.timestamp < uint256(req.requestedAt) + CANCEL_DELAY) revert CancelNotDue();","title":"cancelRequest is a free option once a VRF fulfilment is 24 h late: the spinner can front-run a visible losing word with cancelRequest and let a winning one land"},{"citation":"resolved","description":"README launch 2 says 'vault, gacha and POLManager must share $contract:TwapOracle: bindGacha checks every one of these'. bindGacha only checks vault.oracle == gacha.oracle; it never checks that the oracle's genesisToken()/goldHook() equal this hook's token/address, nor that POLManager's oracle/token/hook match. Separately, the deployed GoblinNFT.setLaunchTargets only requires !released, so after bindGacha(G1) the owner can name G2 and release() sends the escrow to G2 while hook fees keep going to G1. Both depend on the NFT owner and the 7-day holder review (a trust assumption), so this is info. Fix: also require TwapOracle(gacha.oracle()).goldHook() == address(this) && genesisToken() == token and POLManager(gacha.polManager()).oracle()/hook()/token() to match, or correct the README claim; document the post-bind retarget as a trust assumption.","line":300,"path":"src/GoldHook.sol","reproduction":"The owner names gacha G whose oracle is an arbitrary contract O (O.genesisToken() is not GOLD) with vault.oracle = O. After 7 days bindGacha(G) succeeds (lines 296-300 pass) although the README says the oracle is checked. Or: bind G1, then setLaunchTargets(pol, G2, G2) succeeds (GoblinNFT.sol:203-204 only checks !released) and release() credits G2.","severity":"info","snippet":"        if (IHookGenesisVault(vault).oracle() != IHookGacha(gacha_).oracle()) revert GachaMismatch();","title":"bindGacha does not check the oracle's or POLManager's wiring as the README claims, and the NFT owner can re-run setLaunchTargets after binding"},{"citation":"resolved","description":"Brief item 4. The repository contains no IMD manifest grammar and the README itself conditions path B on 'if the manifest allows remainderTo = $contract:GoldHook', so whether a $contract: reference is accepted as remainder recipient is an IMD-side rule this review cannot confirm; it stays an open item for the requester. What the code guarantees: under path B the only exit for GOLD held by the hook is a successful bindGacha (see the bind lock-out finding), and genesisSupply must be <= the amount the hook forwards or GenesisVault.quote returns 0 forever (balanceOf(vault) < genesisSupply - withdrawn). Under path A the paying wallet (also the deployed GoblinNFT owner) custodies the whole genesis supply with no on-chain commitment to transfer it; the vault quotes nothing until it does. Recommendation: if IMD accepts $contract:GoldHook, prefer path B together with a bind pre-flight check before setLaunchTargets; otherwise record the path-A transfer as a runbook step with the exact genesisSupply and verify balanceOf(vault) == genesisSupply before setLaunchTargets.","line":365,"path":"README.md","reproduction":"Not a code defect; operational. State: launch 1 with remainderTo = $contract:GoldHook, then setLaunchTargets never called or called with polTarget != BagGacha.polManager(). Expected per README: GOLD reaches GenesisVault at bind. Actual: bindGacha reverts NotLaunchTarget and IERC20(GOLD).balanceOf(hook) stays at the full remainder with no function able to move it.","severity":"info","snippet":"  - **Path B — hook custody (no wallet ever holds GOLD), if the manifest allows `remainderTo = $contract:GoldHook`:**","title":"Manifest remainder path: path B validity cannot be verified from the tree, and path B depends on bindGacha ever succeeding; path A leaves the supply in wallet custody with no on-chain commitment"}],"hash":"95139b8ed4a140fefb281fd4b54b49c6d33d6ccc26efcc0b861508fa224e6e2e","nodeId":"f1bee5cb-f343-48ea-91ac-cf604e836f6f","outcome":"completed","summary":"Review complete. The findings file `.imd-findings.json` holds 8 verified findings; no source files were changed, and only `test/scratch/` and the findings file were written.\n\n**Build and test results (brief item 1)**\n\n| Profile | Result |\n|---|---|\n| Launch profile (via_ir=false, bytecode_hash=none) | 160 passed, 2 fork tests skipped |\n| Protected foundry.toml (via_ir=true) | 160 passed, 2 skipped |\n| Pinned gacha hash under launch profile | matches 0x2db1…c4cd |\n| Pinned gacha hash under protected profile | differs (0x9796…361b), test self-skips as documented |\n\n**Findings kept, after reproducing each against the code**\n\n- **High, concentrated liquidity inflates genesis depth and cap** (TwapOracle line 236). Two specialists reported the same defect. Merged into one. Their proof fails on v3.2 under both profiles: about 1 ETH in a 120-tick position lifts the GOLD payout cap from 0.1 ETH to 16.75 ETH, and 0.03 ETH lifts a 0.5 ETH pool over MIN_GENESIS_DEPTH. Proof attached.\n- **High, POLManager slippage floor inverted** (POLManager line 122). The finding-5 switch to the smaller window quote is right for the vault but wrong for a buyer. A 31-minute pump lets buyAndBurn execute 35% below the 4-hour quote. Proof attached and fails as claimed.\n- **Medium, v3 cap read at call time** (TwapOracle line 261). I wrote my own mock reproduction. The cap jumps from 0.25 ETH to 5 ETH inside one transaction. Merged with the out-of-range WETH balance low, since both sit in the same function and share a fix.\n- **Medium, genesis depth valued at spot price** (TwapOracle line 228). A 4-hour pump raises the cap by sqrt of the pump and an in-transaction swap passes the depth gate. Merged the economics low into this one.\n- **Medium, bindGacha lock-out after release** (GoldHook line 289). Confirmed against GoblinNFT: release is permissionless and freezes targets, so a mis-named set strands the reserve share forever. The via_ir hash mismatch makes this a plausible operator error.\n- **Low, cancelRequest free option** on a visible late VRF word. Three identical reports merged into one.\n- **Info, bindGacha oracle wiring and post-bind retargeting** (README claim overstates the checks).\n- **Info, manifest remainder path**. Path B validity is an IMD-side rule not verifiable from the tree. Both custody risks are stated.\n\n**Dropped or not found**: nothing was dropped as non-reproducing. My own checks of hook reentrancy, the Curator pause interaction with cancelRequest, and the afterSwap partial-fill rule found no additional defect.","treeHash":null,"usage":{"cachedInputTokens":2358556,"inputTokens":706,"model":"claude-fable-5-1","outputTokens":28606,"runtime":"claude","turns":28,"wallClockMs":790131}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"45aa937328087de3","findings":[{"citation":"resolved","description":"v3.1 `TwapOracle.tokensForEth(GOLD)` returned `Math.max` of the 30-minute and 4-hour window quotes (the window giving MORE tokens per ETH), which is the protective direction for a buyer's minimum-output floor. The v3.2 fix for finding 5 changed it to `Math.min` (src/TwapOracle.sol:176-177) for the GenesisVault quote, but POLManager still derives `minOut` from the same function. For a buyer, the smaller token quote is the LESS protective one: when the 30-minute TWAP is pumped above the 4-hour TWAP, the floor follows the pumped window and `buyAndBurn` / `addLiquidity` (both permissionless, up to 1 ETH / 2 ETH per call, repeatable until the budget is spent) execute at the pumped price with only the 5.25% allowance against the pumped 30-minute mark. README states MIN_OUT_BPS makes these revert on 'a manipulated pool'; that guarantee now only holds against a pump shorter than 30 minutes. An attacker holding GOLD pumps the pool for 31 minutes, then calls buyAndBurn/addLiquidity in a loop and sells into the POL's buys; the POL budgets (50% of the NFT escrow, hook reserve shares, spin POL shares) are spent at an inflated price. Fix: keep the Genesis semantics (min) for the vault, but give POLManager a floor that follows the larger of the two window quotes, e.g. add `TwapOracle.polTokensForEth(uint256 ethAmount)` returning `Math.max(short, long)` for GOLD (the v3.1 behaviour) and call it from `_minOut`; or compute `_minOut` from `genesisValueAndCap`'s 4-hour tick. The regression test is `test/scratch/PolFloorInverted.t.sol`.","line":122,"path":"src/POLManager.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {Math} from \"@openzeppelin/contracts/utils/math/Math.sol\";\nimport {PoolManager} from \"test/v4-core/PoolManager.sol\";\nimport {IPoolManager} from \"test/v4-core/interfaces/IPoolManager.sol\";\nimport {IHooks} from \"test/v4-core/interfaces/IHooks.sol\";\nimport {Currency} from \"test/v4-core/types/Currency.sol\";\nimport {PoolKey as CoreKey} from \"test/v4-core/types/PoolKey.sol\";\nimport {SwapParams as CoreSwap, ModifyLiquidityParams as CoreModify} from \"test/v4-core/types/PoolOperation.sol\";\nimport {PoolSwapTest} from \"test/v4-core/test/PoolSwapTest.sol\";\nimport {PoolModifyLiquidityTest} from \"test/v4-core/test/PoolModifyLiquidityTest.sol\";\nimport {GoldHook} from \"src/GoldHook.sol\";\nimport {TwapOracle} from \"src/TwapOracle.sol\";\nimport {POLManager} from \"src/POLManager.sol\";\nimport {TickMath} from \"src/TickMath.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\n\ncontract ScratchGold is ERC20 {\n    constructor() ERC20(\"GOLD\", \"GOLD\") {}\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\ncontract ScratchV3Factory {\n    function getPool(address, address, uint24) external pure returns (address) {\n        return address(0);\n    }\n}\n\n/// @notice v3.2 finding 5 changed `TwapOracle.tokensForEth(GOLD)` from the larger token amount of the two windows to\n///         the smaller one, for the GenesisVault quote. POLManager uses the same function as its slippage floor\n///         (`minOut = tokensForEth(GOLD, ethIn) * 9475 / 10000`), where the protective direction is the opposite:\n///         the floor must follow the window that gives MORE tokens, otherwise a 30-minute pump of the GOLD price\n///         lowers the floor and the permissionless `buyAndBurn` / `addLiquidity` buy GOLD at the pumped price.\n///         This test fails on v3.2 (the buy goes through at ~60% of the 4-hour TWAP token amount) and passes once\n///         POLManager's floor again follows the larger of the two window quotes.\ncontract PolFloorInvertedTest is Test {\n    uint160 constant SQRT_PRICE_1_1 = 79228162514264337593543950336;\n    address constant HOOK_ADDRESS = address((uint160(0x4A) << 152) | 0x30CC);\n    address factoryEOA = address(0xFAC7);\n    address trader = address(0x7A4D);\n\n    PoolManager manager;\n    ScratchGold gold;\n    GoldHook hook;\n    PoolSwapTest swapRouter;\n    PoolModifyLiquidityTest lpRouter;\n    CoreKey key;\n    TwapOracle oracle;\n    POLManager pol;\n\n    receive() external payable {}\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        manager = new PoolManager(address(this));\n        gold = new ScratchGold();\n        GoldHook impl = new GoldHook(address(manager), address(gold), factoryEOA);\n        vm.etch(HOOK_ADDRESS, address(impl).code);\n        hook = GoldHook(payable(HOOK_ADDRESS));\n        swapRouter = new PoolSwapTest(IPoolManager(address(manager)));\n        lpRouter = new PoolModifyLiquidityTest(IPoolManager(address(manager)));\n        key = CoreKey(Currency.wrap(address(0)), Currency.wrap(address(gold)), 12500, 60, IHooks(HOOK_ADDRESS));\n        vm.deal(trader, 1000 ether);\n        vm.deal(address(this), 10_000 ether);\n        vm.prank(factoryEOA);\n        manager.initialize(key, SQRT_PRICE_1_1);\n        // seed 100 ETH / 100 GOLD full range (L = 100e18), as the launch would\n        gold.mint(address(this), 100 ether);\n        gold.approve(address(lpRouter), 100 ether);\n        lpRouter.modifyLiquidity{value: 100 ether}(key, CoreModify(-887220, 887220, 100e18, bytes32(0)), \"\");\n\n        oracle = new TwapOracle(address(this), address(0xBEEF), address(new ScratchV3Factory()), address(gold), HOOK_ADDRESS);\n        pol = new POLManager(address(manager), address(gold), HOOK_ADDRESS, address(oracle), address(0xEEEE));\n    }\n\n    function _swap(bool zeroForOne, int256 amountSpecified) internal {\n        uint160 limit = zeroForOne ? 4295128739 + 1 : 1461446703485210103287273052203988822378723970342 - 1;\n        uint256 value;\n        if (zeroForOne) {\n            value = uint256(-amountSpecified);\n        } else {\n            gold.mint(trader, 1_000_000 ether);\n            vm.prank(trader);\n            gold.approve(address(swapRouter), type(uint256).max);\n        }\n        vm.prank(trader);\n        swapRouter.swap{value: value}(\n            key, CoreSwap(zeroForOne, amountSpecified, limit), PoolSwapTest.TestSettings(false, false), \"\"\n        );\n    }\n\n    function _tokensAtTick(int24 tick, uint256 ethAmount) internal pure returns (uint256) {\n        uint160 sqrtRatio = TickMath.sqrtPriceAtTick(tick);\n        return Math.mulDiv(uint256(sqrtRatio) * sqrtRatio, ethAmount, 1 << 192);\n    }\n\n    function _meanTicks() internal view returns (int24 shortTick, int24 longTick) {\n        uint32[] memory agos = new uint32[](3);\n        agos[0] = 30 minutes;\n        agos[1] = 4 hours;\n        int56[] memory c = hook.observe(agos);\n        shortTick = int24((c[2] - c[0]) / int56(uint56(30 minutes)));\n        longTick = int24((c[2] - c[1]) / int56(uint56(4 hours)));\n    }\n\n    function testThirtyMinutePumpLowersThePolFloorAndTheBurnBuysAtThePumpedPrice() public {\n        // GOLD priceable: a swap past the tax window records the seeded liquidity, 4 hours of ring history\n        vm.warp(vm.getBlockTimestamp() + 1 hours);\n        _swap(true, -0.001 ether);\n        vm.warp(vm.getBlockTimestamp() + 4 hours);\n        _swap(false, -0.001 ether);\n        uint256 fair = oracle.tokensForEth(address(gold), 0.1 ether);\n        assertApproxEqRel(fair, 0.1 ether, 0.01e18, \"1:1 pool: 0.1 GOLD per 0.1 ETH\");\n\n        // attacker pumps GOLD: 30 ETH into a 100 ETH-deep pool, held for 31 minutes\n        _swap(true, -30 ether);\n        vm.warp(vm.getBlockTimestamp() + 31 minutes);\n        (int24 s, int24 l) = _meanTicks();\n        uint256 shortQuote = _tokensAtTick(s, 0.1 ether);\n        uint256 longQuote = _tokensAtTick(l, 0.1 ether);\n        assertLt(shortQuote, (longQuote * 70) / 100, \"the 30-minute window is at the pumped price\");\n        assertGt(longQuote, (fair * 90) / 100, \"the 4-hour window barely moved\");\n\n        // v3.2: the POL floor now follows the pumped window (fewer tokens), not the 4-hour one\n        uint256 floorNow = (oracle.tokensForEth(address(gold), 0.1 ether) * pol.MIN_OUT_BPS()) / 10_000;\n        uint256 floorProtective = (longQuote * pol.MIN_OUT_BPS()) / 10_000;\n        emit log_named_uint(\"floor used by POLManager (GOLD per 0.1 ETH)\", floorNow);\n        emit log_named_uint(\"floor at the 4-hour TWAP               \", floorProtective);\n\n        // a permissionless burn of POL money must NOT clear at the pumped price: the pool pays far less than the\n        // 4-hour quote minus the 5.25% allowance, which is what MIN_OUT_BPS is documented to guarantee\n        pol.depositBurn{value: 1 ether}();\n        vm.expectRevert(POLManager.Slippage.selector);\n        pol.buyAndBurn(0.1 ether);\n    }\n}","reproduction":"Real vendored PoolManager, GoldHook at a flagged address, pool seeded 100 ETH / 100 GOLD (L = 100e18), real TwapOracle(goldHook) and POLManager(oracle). 1) warp 1h, swap 0.001 ETH; warp 4h, swap 0.001 GOLD: tokensForEth(GOLD, 0.1 ETH) ~ 0.1 GOLD. 2) Attacker buys with 30 ETH (exact input), warp 31 minutes. 30-minute mean tick quote = 0.0598 GOLD per 0.1 ETH, 4-hour quote = 0.0936. 3) pol.depositBurn{value: 1 ether}(); pol.buyAndBurn(0.1 ether). Expected (README, v3.1): revert Slippage because the pool pays < 4-hour quote * 0.9475 = 0.0887 GOLD. Actual on v3.2: minOut = 0.0598 * 0.9475 = 0.0566 GOLD and the burn executes at the pumped price (~0.0578 GOLD per 0.1 ETH, 35% fewer tokens than the 4-hour TWAP). Under the launch profile: `FOUNDRY_VIA_IR=false FOUNDRY_BYTECODE_HASH=none forge test --match-path test/scratch/PolFloorInverted.t.sol` fails with 'next call did not revert as expected'; with the GOLD branch of tokensForEth restored to Math.max it passes.","severity":"high","snippet":"        return (oracle.tokensForEth(token, ethIn) * MIN_OUT_BPS) / 10_000;","title":"POLManager slippage floor inverted by the finding-5 change: tokensForEth(GOLD) now returns the smaller window quote, so a 30-minute pump lets buyAndBurn/addLiquidity buy GOLD at the pumped price"},{"citation":"resolved","description":"The v3.2 fix for audit finding 4 ('JIT liquidity inflated ... the 5% payout cap') bounds only the genesis (v4) pool by `min(current L, liquidityAt(30 min))`. `_liquidBalances`, which feeds `valueAndCap`/`payoutCap` for every regular token, still uses `pool.liquidity()` and `weth.balanceOf(pool)` at call time. The cap is read in `BagGacha._buildSnapshot` during the spin request, so a depositor can, in the same transaction, flash-loan WETH+token, mint a narrow v3 position around the current tick (raising `liquidity()` by orders of magnitude for little capital while the WETH balance rises by the deposited amount), call `spin()` so the snapshot commits the uncapped Bag amounts, and burn the position. A 70P (0.7 ETH) Bag of a token whose pool holds the 5 ETH minimum is then paid in one draw (0.665 ETH to the depositor on keep) instead of being split at the 0.25 ETH cap, removing the per-draw bound the cap was introduced for (README: 'bounds the extraction per draw'). Fix: mirror the genesis bound for v3 pools: the v3 pool's `observe` also returns `secondsPerLiquidityCumulativeX128`; derive the harmonic-mean active liquidity over the 30-minute window from the two cumulatives and use `min(current liquidity, 30-minute mean liquidity)` in `_liquidBalances` (extend `IUniswapV3PoolOracle.observe` to decode the second array). Liquidity added inside the transaction then cannot raise the cap, exactly as for GOLD.","line":261,"path":"src/TwapOracle.sol","reproduction":"test/scratch/V3JitCap.t.sol (project mocks): TwapOracle with MockV3Pool(token, weth) listed via setPool; pool holds 5 WETH / 5 TOK with liquidity 5e18 -> payoutCap(token) = 0.25 ETH. In the same block/transaction mint 95 WETH + 95 TOK to the pool and set liquidity to 100e18 -> payoutCap(token) = 5 ETH. Expected (the finding-4 property the genesis pool now has, see testJitPayoutCap): the cap does not move inside one transaction. Actual: it rises 20x at once; `forge test --match-path test/scratch/V3JitCap.t.sol` fails with '5000000000000000000 != 250000000000000000'.","severity":"medium","snippet":"        uint128 liquidity = IUniswapV3PoolOracle(pool).liquidity();","title":"Finding-4 fix incomplete: the payout cap of regular (Uniswap v3) tokens is still read from the pool's current liquidity and WETH balance, so it is raised inside one transaction (JIT)"},{"citation":"resolved","description":"`bindGacha` (finding-1 fix) only accepts the set named through the deployed GoblinNFT's `setLaunchTargets`, and then still requires the pinned runtime code hash and the vault/oracle wiring. The deployed GoblinNFT (0xC5d5..., runtime hash verified against src/GoblinNFT.sol) lets anyone call `release()` the moment the 7-day review ends and `setLaunchTargets` reverts `AlreadyReleased` afterwards (src/GoblinNFT.sol:204, 221-236). If the named set does not satisfy the hook's own checks, the naming can never be corrected after release and `bindGacha` reverts forever. The most likely trigger is in the repository itself: the protected `foundry.toml` has `via_ir = true` while `GACHA_RUNTIME_CODE_HASH` is pinned for `via_ir = false, bytecode_hash = none`; a launch-2 BagGacha compiled with the repository profile has a different runtime hash and fails `CodeMismatch`. Consequences: `reserveAccrued` (half of every 1% hook fee plus all 90/50/25% launch taxes, held as currency-0 ERC6909 claims in the PoolManager) is never flushable, the hook has no other exit for it; under path B the GOLD launch remainder held by the hook is stuck as well, the GenesisVault is never funded and the Genesis lane never opens. The gacha meanwhile does receive the NFT escrow through `pullMintEscrow`, so the failure is silent. Fix (keeps the finding-1 design): (1) add a hook view `bindCheck(address gacha_) returns (bytes4 reason)` evaluating every `bindGacha` precondition except the review timer, and make the runbook require it to return zero (and an `eth_call` of `bindGacha` after the review) before `setLaunchTargets`/`release`; (2) in the runbook/manifest pin the launch-2 build profile (`via_ir=false`, `bytecode_hash=none`) and verify `keccak256(extcodecopy(BagGacha)) == GACHA_RUNTIME_CODE_HASH` on chain before naming; (3) optionally let `bindGacha` accept the named reserve/VRF target after `nft.released()` when only the code-hash check fails, since the owner naming + holder review is then the authority the design relies on, documenting the trade-off.","line":289,"path":"src/GoldHook.sol","reproduction":"State: NFT owner calls setLaunchTargets(polManager, gachaX, gachaX) where gachaX is a BagGacha whose extcodehash != 0x2db1...c4cd (any BagGacha built with the repository's foundry.toml via_ir = true produces a different hash; `forge test --match-test testPinnedGachaRuntimeCodeHash -vv` without FOUNDRY_VIA_IR=false prints 0x9796d1370679550f382780faab25b5d150ed263364ab04c5178a2c3741dd361b under the repository's foundry.toml, verified in this review, versus the pinned 0x2db1143638494d63cd5b4b807bd3dfe333d590ea58e7204ab7fd0c5cf415c4cd), or whose GenesisVault.oracle() != gachaX.oracle(). Collection sells out, 7 days pass, anyone calls GoblinNFT.release(). Then: GoldHook.bindGacha(gachaX) reverts CodeMismatch/GachaMismatch; GoblinNFT.setLaunchTargets(...) reverts AlreadyReleased; every later GoldHook.flush() pays holders only and reserveAccrued keeps growing with no function able to move it (the hook has no owner, no sweep); IERC20(GOLD).balanceOf(hook) (path B remainder) is immovable. Expected: a recoverable naming mistake, as the 7-day review is documented to provide. Actual: permanent loss of the reserve share and (path B) the genesis supply.","severity":"medium","snippet":"        if (gacha_.codehash != gachaRuntimeCodeHash()) revert CodeMismatch();","title":"bindGacha can be locked out forever: once GoblinNFT.release() runs, the named targets are frozen, so a named set that fails any bindGacha check (e.g. a BagGacha built under the protected via_ir profil"},{"citation":"resolved","description":"GOLD is sealable in regular Bags (`isPriced(GOLD)` is true once the pool is initialized) and is valued at the min of the 30-minute and 4-hour TWAPs. The v3.2 depth bound protects only against liquidity added recently: `ethSide = genesisEthHeld(min(L, liquidityAt(30m)), sqrtPrice)` uses the CURRENT price. A price pump does not change L, and it raises the ETH-side depth (ETH virtual reserve = L*2^96/sqrtP grows as the GOLD price rises) and therefore the cap by sqrt(pump). With the pool at the 1 ETH minimum, pumping GOLD 10x costs about 2.2 ETH of (recoverable) ETH held in the pool for 4 hours plus ~0.1 ETH of fees, provided no other holder dumps into it; the Curator's only answer (pause GOLD) needs a 1-day IMD vote. After 4 hours both windows are ~10x, so a Bag of GOLD sealed 24 h earlier at 0.03 ETH (tier 1) is snapshotted at 0.3 ETH, capped at 5% of the pumped depth (3.16 ETH) = 0.158 ETH and split; a keep pays the depositor 95% = 0.150 ETH from the reserve for tokens worth 0.0158 ETH before the pump. The original Bag keeps its `listedAt` (`_splitBag` leaves it listed, no re-maturation) and is drawable again in the very next spin at the same pumped mark, so the attacker loops spin -> draw -> keep while the pump lasts; each win pays up to min(cap, 5% of reserve). Fix: value the genesis depth and cap at the 4-hour TWAP price rather than spot (`genesisEthHeld(L, sqrtPriceAtTick(longTick))`) and bound L by `liquidityAt(LONG_WINDOW)`, so a pump must be sustained beyond the window that already prices the Bag; and/or exclude the genesis token from `seal` (keep it to the Genesis lane, whose value is bounded by (1-d)P), or raise MIN_GENESIS_DEPTH to a level at which a 4-hour pump costs more than 5% of the reserve. This is the 'TWAP manipulation at minimum depth' case the brief asks about; it is a bounded-but-repeatable reserve drain, hence medium.","line":236,"path":"src/TwapOracle.sol","reproduction":"State: GOLD/ETH pool with L giving 1 ETH ETH-side depth at price 1 (MIN_GENESIS_DEPTH), reserve 10 ETH, GenesisVault unfunded (Genesis lane unavailable, regular lane 100%). Attacker seals Bag{GOLD, 0.03e18} (value 0.03 ETH, tier 1, below 5% of reserve) at T0. At T0+24h buys GOLD with 2.16 ETH (price x10, sqrtP /3.16), waits 4 h with one tiny swap per hour (staleness). At T0+28h: valueInEth(GOLD, 0.03e18) ~ 0.3 ETH (both windows at ~x10), payoutCap(GOLD) = 5% * genesisEthHeld(L, sqrtP/3.16) ~ 0.158 ETH. spin(): bagPayout caps to 0.158 ETH and records the split; VRF word; draw() -> _splitBag -> allocation value 0.158 ETH; choose(keep) -> credits[attacker] += 0.150 ETH, reserve -= 0.150 ETH; the attacker (spinner) also keeps the 0.0158e18 GOLD. Repeat: the remainder Bag is still listed with its original listedAt and is snapshotted again at the pumped mark on the next spin. Expected: a Bag never pays more than its unmanipulated TWAP value and the cap does not grow with the manipulator's own pump. Actual: ~0.15 ETH per win against ~0.016 ETH of real value, repeatable until the reserve or the pump runs out.","severity":"medium","snippet":"        ethSide = genesisEthHeld(liquidity, sqrtPrice);","title":"At MIN_GENESIS_DEPTH a 4-hour GOLD pump inflates both the TWAP value and the genesis-side payout cap of a sealed GOLD Bag, letting the depositor extract up to 5% of the reserve per win for a fraction "},{"citation":"resolved","description":"After CANCEL_DELAY both `cancelRequest()` (full refund of P, earmark released) and the wrapper's fulfilment are valid in the same block. The fulfil transaction carries the random word in calldata, and `draw()` is deterministic from it (lane, tier, Bag). A spinner who sees a losing word pending in the mempool cancels first and gets P back (the VRF fee is sunk either way); a winning word is left to land. Conversely a third party can cancel a visible winning fulfilment to grief the spinner. The precondition (a Chainlink fulfilment delayed over 24 h) is rare, so this is low; it is the only ordering race the new cancel path introduces. Fix: once a fulfilment is 'due' make cancel two-step (anyone announces `cancelRequest()`, which takes effect only in a later block, e.g. after 1 block/minute) or, simpler, let `rawFulfillRandomWords` accept the word even when the request was cancelled within the last N blocks by keeping `vrfRequestId` until the cancel matures; alternatively refund only `P - small penalty` on cancel so the option has a cost.","line":668,"path":"src/BagGacha.sol","reproduction":"State: pending request R by spinner S, requestedAt = t, no fulfilment. At t + 24h the wrapper's fulfil tx for R (word w) appears in the mempool. S computes draw(w) off-chain: regular lane, tier 0 Bag. S submits cancelRequest() with higher priority: pending=false, credits[S]+=P. The fulfil then lands: rawFulfillRandomWords returns early (id mismatch). S re-spins. Expected: the outcome of a paid spin is binding once the word is produced. Actual: S chooses whether the word counts.","severity":"low","snippet":"        if (block.timestamp < uint256(req.requestedAt) + CANCEL_DELAY) revert CancelNotDue();","title":"cancelRequest is a free option once a VRF fulfilment is 24 h late: the spinner (or anyone) can front-run the visible fulfil transaction with cancelRequest after reading the random word from its callda"},{"citation":"resolved","description":"The brief asks whether the remainder path (paying wallet vs `$contract:GoldHook`) is valid for IMD. The repository contains no manifest and the README itself conditions path B on 'if the manifest allows `remainderTo = $contract:GoldHook`', so acceptance of a `$contract:` reference as remainder recipient is an IMD-side rule that this review cannot confirm; it stays an open item for the requester. What the code does guarantee: path B is safe only if `bindGacha` eventually succeeds (see the bind/release finding), since the hook has no other way to move GOLD; and under path B `genesisSupply` must be <= the amount the hook forwards, or `GenesisVault.quote` returns 0 forever (`balanceOf(vault) < genesisSupply - withdrawn`). Under path A the paying wallet 0xA7e9...b5Ac, which is also the deployed GoblinNFT owner (verified via cast: owner() == 0xA7e99BB7155D7477E0C838E201b16E62B9c0b5Ac, targetsSet == false, soldOut == false, liveSupply == 11 at review time), custodies the whole genesis supply with no on-chain commitment to transfer it; the vault quotes nothing until it does. Recommendation: if IMD accepts `$contract:GoldHook`, prefer path B together with the bind pre-flight check; if it does not, record the path-A transfer as a runbook step with the exact `genesisSupply` and verify `balanceOf(vault) == genesisSupply` before `setLaunchTargets`.","line":365,"path":"README.md","reproduction":"Not a code defect. Open item: confirm with IMD that a manifest `remainderTo` may be a `$contract:` reference. Observable state today (mainnet, GoblinNFT 0xC5d5F314da6d4a433F9BF897a2cC24A537576cB1): targetsSet=false, released=false, soldOut=false, liveSupply=11, owner=0xA7e99BB7155D7477E0C838E201b16E62B9c0b5Ac.","severity":"info","snippet":"  - **Path B — hook custody (no wallet ever holds GOLD), if the manifest allows `remainderTo = $contract:GoldHook`:**","title":"Manifest remainder path: path B validity cannot be verified from the tree; both paths carry a documented custody risk and path B depends on bindGacha ever succeeding"}],"hash":"b0fbc9d468443e35be6d1184a86b3835c5d4a1677593798acacbdcc1f7de4d75","nodeId":"e84bbdec-e9ad-4f2a-949c-4d5dad149307","outcome":"completed","summary":"The review is complete and the findings file is written. Source files are untouched (git tree clean apart from the ignored findings file and scratch tests).\n\n**Outcome.** Both profiles build and pass (160 tests, 2 fork tests skipped). The pinned `GACHA_RUNTIME_CODE_HASH` matches BagGacha under the launch profile, and the local `GoblinNFT.sol` matches the deployed runtime hash on mainnet, so the bind preconditions were checked against the real NFT code. Six findings are in `.imd-findings.json`, one high with a Foundry proof.\n\n**Findings**\n\n- **High, POLManager slippage floor inverted.** The finding-5 fix changed `tokensForEth(GOLD)` from the larger to the smaller window quote for the GenesisVault, but POLManager's `_minOut` still uses it. For a buyer the smaller quote is the unprotective direction. After a 30-minute pump, `buyAndBurn` and `addLiquidity` (both permissionless) buy GOLD at the pumped price with the floor 36% below the 4-hour quote. Proof in `test/scratch/PolFloorInverted.t.sol` fails on current code under both profiles and passes with the v3.1 max floor restored. Fix: a separate POL quote that takes the larger window.\n- **Medium, finding-4 fix incomplete.** The regular Uniswap v3 payout cap still reads current liquidity and WETH balance, so it rises 20x inside one transaction (0.25 ETH to 5 ETH in `test/scratch/V3JitCap.t.sol`). Fix: bound by the 30-minute harmonic-mean liquidity from v3's `secondsPerLiquidityCumulative`.\n- **Medium, bind lockout after release.** `release()` freezes the named targets. A named set that fails any `bindGacha` check can never be renamed, stranding the reserve share and, under path B, the genesis GOLD. Concrete trigger: a BagGacha built with the repository's own `foundry.toml` hashes to `0x9796…361b`, not the pinned value. Fix: a pre-flight view plus runbook gating before `setLaunchTargets`.\n- **Medium, GOLD-bag pump at minimum depth.** The genesis depth uses spot price, so a 4-hour pump raises both the Bag's TWAP value and its cap. At 1 ETH depth a 2.2 ETH pump lets a sealed GOLD Bag pay about 0.15 ETH per win for 0.016 ETH of value, repeatable because the split remainder keeps its `listedAt`. Fix: price the depth at the 4-hour tick or exclude GOLD from `seal`.\n- **Low, cancel race.** After 24 hours a visible VRF fulfilment can be front-run with `cancelRequest`, making the word optional for the spinner.\n- **Info, manifest remainder path.** Path B's validity depends on IMD accepting a `$contract:` recipient, which the tree cannot confirm. Mainnet state today: targets unset, not sold out, 11 live tokens, owner is the paying wallet.\n\n**Verified fixes with no defect found.** POL positive deltas, afterSwap PartialFill including the exact-output `amount + fee` rule, recorded liquidity min and the `genesisEthHeld` bound, `genesisValueAndCap` at the 4-hour TWAP, `MIN_OUT_BPS` 9475, exact-output gross fee, and cancel's independence from Curator pauses all hold as described. Hook reentrancy and stuck-fund paths in `flush` and `unlockCallback` were traced without issue.","treeHash":null,"usage":{"cachedInputTokens":2259029,"inputTokens":610,"model":"claude-fable-5-1","outputTokens":50409,"runtime":"claude","turns":38,"wallClockMs":1173521}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"2565f234b0a569e9","findings":[{"citation":"resolved","description":"The v3.2 fix for 85083c05 finding 4 (JIT depth) takes L = min(current active liquidity, liquidityAt(30 minutes)) and converts it to ETH with genesisEthHeld(L, sqrtP) = L*2^96/sqrtP (up to tick 887220), i.e. the ETH a FULL-RANGE position of liquidity L would hold. Uniswap v4 active liquidity is the sum of every position whose range contains the current tick, and a position that is one tick spacing wide (60 ticks, about +-0.3%) contributes the same L while holding only L*(sqrtU-sqrtP)/(sqrtP*sqrtU) of ETH, about 0.3% of the full-range figure. The recorded-liquidity rule only delays the attacker by 30 minutes (the position must be active when a swap records it and still be active 30 minutes later; the attacker can keep the price inside +-0.3% by trading in a thin pool), it does not bound the depth by ETH the pool holds. Consequences: (1) MIN_GENESIS_DEPTH (1 ETH) is passed with ~0.3 ETH of real liquidity, so the Genesis lane and GOLD Bags open on a pool that cannot absorb the payouts; (2) PAYOUT_CAP_BPS (5% of depth), the one per-draw bound the README relies on against a manipulated GOLD TWAP ('5% of the pool's ETH-side depth, the most a single draw may pay out'), is loosened arbitrarily: in the proof, ~1 ETH of attacker liquidity moves the cap from 5.0 ETH to 21.65 ETH while the whole pool holds 101 ETH, so a GOLD Bag whose TWAP has been pushed pays up to the tier maximum (0.7 ETH) / 5% of the reserve from the gacha reserve instead of 5% of what the pool can actually return. Fix (preserves the design): bound the genesis depth by ETH that is provably in the pool for the long term, not by active liquidity. Options: (a) make TwapOracle read, via extsload of pools[id].positions[keccak256(abi.encodePacked(owner, int24(-887220), int24(887220), bytes32(0)))], the liquidity of the full-range positions owned by the trusted holders (POLManager and the launch seed owner) and use min(that, current L, liquidityAt(30m)) in genesisEthHeld; or (b) give the hook afterAddLiquidity/afterRemoveLiquidity permissions (new mined address) and track only full-range liquidity in the observation ring; or (c) walk the initialized ticks from the current tick up to 887220 (tickBitmap/ticks via extsload) and sum the real amount0 of each liquidity segment. Also apply the same reasoning to the v3 path: _liquidBalances bounds by the pool's WETH balance, which out-of-range positions also count toward.","line":236,"path":"src/TwapOracle.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 \"test/v4-core/PoolManager.sol\";\nimport {IPoolManager} from \"test/v4-core/interfaces/IPoolManager.sol\";\nimport {IHooks} from \"test/v4-core/interfaces/IHooks.sol\";\nimport {Currency} from \"test/v4-core/types/Currency.sol\";\nimport {PoolKey as CoreKey} from \"test/v4-core/types/PoolKey.sol\";\nimport {SwapParams as CoreSwap, ModifyLiquidityParams as CoreModify} from \"test/v4-core/types/PoolOperation.sol\";\nimport {PoolSwapTest} from \"test/v4-core/test/PoolSwapTest.sol\";\nimport {PoolModifyLiquidityTest} from \"test/v4-core/test/PoolModifyLiquidityTest.sol\";\nimport {GoldHook} from \"src/GoldHook.sol\";\nimport {TwapOracle} from \"src/TwapOracle.sol\";\n\ncontract ScratchGold is ERC20 {\n    constructor() ERC20(\"GOLD\", \"GOLD\") {}\n\n    function mint(address to, uint256 a) external {\n        _mint(to, a);\n    }\n}\n\n/// @notice The v3.2 genesis depth (`TwapOracle._checkGenesisPool` / `genesisEthHeld`) treats the pool's *active*\n///         liquidity as if it spanned the full range: depth = L * 2^96 / sqrtP (up to tick 887220). A position that is\n///         only one tick spacing wide (60 ticks, +-0.3%) around the current price contributes the same `L` to the\n///         active liquidity with ~0.3% of the ETH a full-range position of that `L` would hold. The JIT fix (finding\n///         4 of 85083c05) only delays this by 30 minutes: it does not bound the depth by ETH the pool actually holds.\ncontract ConcentratedDepthTest is Test {\n    uint160 constant SQRT_PRICE_1_1 = 79228162514264337593543950336;\n    address constant HOOK_ADDRESS = address((uint160(0x4A) << 152) | 0x30CC);\n    address factoryEOA = address(0xFAC7);\n    address trader = address(0x7A4D);\n    address attacker = address(0xA77A);\n\n    PoolManager manager;\n    ScratchGold gold;\n    GoldHook hook;\n    TwapOracle oracle;\n    PoolSwapTest swapRouter;\n    PoolModifyLiquidityTest lpRouter;\n    CoreKey key;\n\n    receive() external payable {}\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        manager = new PoolManager(address(this));\n        gold = new ScratchGold();\n        GoldHook impl = new GoldHook(address(manager), address(gold), factoryEOA);\n        vm.etch(HOOK_ADDRESS, address(impl).code);\n        hook = GoldHook(payable(HOOK_ADDRESS));\n        assertTrue(hook.hasRequiredFlags());\n        // weth / v3 factory are never touched on the genesis path\n        oracle = new TwapOracle(address(this), address(0x1111), address(0x2222), address(gold), HOOK_ADDRESS);\n        swapRouter = new PoolSwapTest(IPoolManager(address(manager)));\n        lpRouter = new PoolModifyLiquidityTest(IPoolManager(address(manager)));\n        key = CoreKey(Currency.wrap(address(0)), Currency.wrap(address(gold)), 12500, 60, IHooks(HOOK_ADDRESS));\n        vm.deal(trader, 100 ether);\n        vm.deal(attacker, 100 ether);\n        vm.deal(address(this), 10_000 ether);\n        vm.prank(factoryEOA);\n        manager.initialize(key, SQRT_PRICE_1_1);\n    }\n\n    function _addLiquidity(address who, int24 lower, int24 upper, int256 liquidity, uint256 ethValue) internal {\n        gold.mint(who, 10_000e18);\n        vm.startPrank(who);\n        gold.approve(address(lpRouter), type(uint256).max);\n        lpRouter.modifyLiquidity{value: ethValue}(key, CoreModify(lower, upper, liquidity, bytes32(0)), \"\");\n        vm.stopPrank();\n    }\n\n    function _swap(bool zeroForOne, int256 amountSpecified) internal {\n        uint160 limit = zeroForOne ? 4295128739 + 1 : 1461446703485210103287273052203988822378723970342 - 1;\n        uint256 value = zeroForOne ? uint256(-amountSpecified) : 0;\n        if (!zeroForOne) {\n            gold.mint(trader, 1_000e18);\n            vm.prank(trader);\n            gold.approve(address(swapRouter), type(uint256).max);\n        }\n        vm.prank(trader);\n        swapRouter.swap{value: value}(\n            key, CoreSwap(zeroForOne, amountSpecified, limit), PoolSwapTest.TestSettings(false, false), \"\"\n        );\n    }\n\n    /// @dev Full-range launch seed of 100 ETH / 100 GOLD (L = 100e18), then enough history for both TWAP windows.\n    function _seedAndAge() internal {\n        _addLiquidity(address(this), -887220, 887220, 100e18, 100 ether);\n        vm.warp(vm.getBlockTimestamp() + 1 hours);\n        _swap(true, -0.001 ether); // records the seeded liquidity\n        vm.warp(vm.getBlockTimestamp() + 4 hours);\n        _swap(false, -0.001 ether);\n    }\n\n    /// @notice The payout cap is documented as 5% of \"the ETH that liquidity holds\". After a 60-tick-wide position\n    ///         that holds ~1 ETH has been active for 30 minutes, the cap counts ~333 ETH that is not in the pool.\n    function testOneTickWidePositionInflatesGenesisDepthAndPayoutCap() public {\n        _seedAndAge();\n        uint256 cap0 = oracle.payoutCap(address(gold));\n        assertApproxEqRel(cap0, 5 ether, 0.01e18, \"100 ETH of full-range depth: cap 5 ETH\");\n\n        // attacker: L = 333e18 between ticks -60 and +60 costs ~1 ETH and ~1 GOLD\n        uint256 managerEthBefore = address(manager).balance;\n        _addLiquidity(attacker, -60, 60, 333e18, 2 ether);\n        uint256 ethAdded = address(manager).balance - managerEthBefore;\n        assertLt(ethAdded, 1.01 ether, \"the narrow position holds about 1 ETH\");\n        assertEq(hook.liquidity(), 433e18);\n\n        // next block's swap records it; 30 minutes later it is the depth the oracle trusts\n        vm.warp(vm.getBlockTimestamp() + 1);\n        _swap(true, -0.0001 ether);\n        vm.warp(vm.getBlockTimestamp() + 30 minutes);\n        assertEq(hook.liquidityAt(30 minutes), 433e18);\n\n        uint256 cap = oracle.payoutCap(address(gold));\n        uint256 ethInPool = address(manager).balance; // the only pool in this manager: every wei is this pool's ETH\n        // expected: the cap never exceeds 5% of the ETH the pool actually holds (~101 ETH -> ~5.05 ETH)\n        // actual (v3.2): ~21.6 ETH, 5% of a 433 ETH \"depth\" of which ~332 ETH does not exist\n        assertLe(cap, (ethInPool * 500) / 10_000, \"payout cap counts ETH the pool does not hold\");\n    }\n\n    /// @notice MIN_GENESIS_DEPTH (1 ETH) is passed with ~0.3 ETH of real liquidity in a one-spacing-wide position.\n    function testOneTickWidePositionPassesMinGenesisDepth() public {\n        // launch seed below the depth floor: 0.5 ETH / 0.5 GOLD full range\n        _addLiquidity(address(this), -887220, 887220, 0.5e18, 0.5 ether);\n        vm.warp(vm.getBlockTimestamp() + 1 hours);\n        _swap(true, -0.0001 ether);\n        vm.warp(vm.getBlockTimestamp() + 4 hours);\n        _swap(false, -0.0001 ether);\n        vm.expectRevert(abi.encodeWithSelector(TwapOracle.ThinPool.selector, HOOK_ADDRESS));\n        oracle.payoutCap(address(gold));\n\n        // attacker: L = 100e18 in [-60, 60] holds ~0.3 ETH; the pool now holds ~0.8 ETH in total\n        uint256 managerEthBefore = address(manager).balance;\n        _addLiquidity(attacker, -60, 60, 100e18, 1 ether);\n        assertLt(address(manager).balance - managerEthBefore, 0.31 ether);\n        vm.warp(vm.getBlockTimestamp() + 1);\n        _swap(true, -0.0001 ether);\n        vm.warp(vm.getBlockTimestamp() + 30 minutes);\n        assertLt(address(manager).balance, 1 ether, \"the pool holds under 1 ETH\");\n\n        // expected: still ThinPool (the pool holds < MIN_GENESIS_DEPTH). actual (v3.2): priced, depth ~100.5 ETH\n        vm.expectRevert(abi.encodeWithSelector(TwapOracle.ThinPool.selector, HOOK_ADDRESS));\n        oracle.payoutCap(address(gold));\n    }\n}","reproduction":"State: GOLD/ETH v4 pool seeded full range with 100 ETH / 100 GOLD (L = 100e18, price 1:1), observations older than 4 h, so oracle.payoutCap(GOLD) = 5.0 ETH. Input: attacker calls modifyLiquidity(tickLower=-60, tickUpper=60, liquidityDelta=333e18) paying ~0.998 ETH and ~1 GOLD; any swap in the next block records L = 433e18; wait 30 minutes. Expected: cap <= 5% of the ETH in the pool, i.e. <= 5.05 ETH, and depth <= ETH held. Actual: hook.liquidityAt(30 min) = 433e18, oracle.payoutCap(GOLD) = 21,650,002,748,118,531,620 wei (21.65 ETH) while address(PoolManager).balance (all of this pool's ETH) is 100,997,542,650,284,486,844 wei; the oracle reports a 433 ETH depth of which ~332 ETH does not exist. Second case: full-range seed of 0.5 ETH (ThinPool as designed), then a [-60,60] position with L = 100e18 costing 0.30 ETH: 30 minutes after it is recorded, payoutCap no longer reverts ThinPool although the pool holds 0.80 ETH < MIN_GENESIS_DEPTH. Run: FOUNDRY_VIA_IR=false FOUNDRY_BYTECODE_HASH=none forge test --match-path test/scratch/ConcentratedDepth.t.sol (both tests fail on v3.2).","severity":"high","snippet":"        ethSide = genesisEthHeld(liquidity, sqrtPrice);","title":"Genesis depth and payout cap count concentrated liquidity as full-range ETH: a one-spacing-wide position inflates both ~330x for 30 minutes of patience"},{"citation":"resolved","description":"For regular Uniswap v3 token/WETH pools the ETH-side depth is min(pool WETH balance, L_active * virtual reserve). The virtual reserve has the same full-range assumption as the genesis path (see the high finding): a narrow in-range position gives a large L_active with little capital. The balance bound was meant to cap that, but balanceOf(pool) counts WETH held by positions that are entirely out of range (and plain transfers to the pool), which cannot absorb a sale of the token at the current price. Both conditions of _checkPool/_liquidBalances are therefore satisfiable with ~5 ETH parked far above the price (recoverable by the attacker) plus a one-spacing-wide position of a few hundredths of an ETH, giving MIN_LIQUIDITY compliance and a 0.25 ETH per-draw cap on a pool whose active depth is a few hundredths of an ETH. The Curator panel vets listings, but setPool only re-runs this same check, and valueAndCap runs it at every draw. Fix: bound the v3 ETH side by the amount0/amount1 the active liquidity actually holds within a fixed band around the price (e.g. the ETH held from the current tick to the tick of the next initialized liquidity edge, or simply within +-5%), or require the listed pool's full-range (or wide-range) liquidity rather than L_active.","line":265,"path":"src/TwapOracle.sol","reproduction":"State: a listed v3 pool TOKEN/WETH where the attacker holds (1) a position [tickUpper-of-price+6000, +12000] containing 5 WETH and no token (out of range, recoverable later), (2) 5 ETH worth of TOKEN in a symmetric out-of-range position below the price, and (3) an in-range position of one tick spacing holding 0.02 WETH and 0.02 ETH worth of TOKEN with liquidity L such that L*2^96/sqrtP >= 5e18. Input: TwapOracle.validatePool(TOKEN, pool) or valueAndCap(TOKEN, amount). Expected: ThinPool(pool), since fewer than 5 ETH (0.02 ETH) can be sold into near the current price. Actual: wethSide = min(5e18, virtual) = 5e18 and tokenSide = min(5 ETH worth, virtual) >= 5 ETH worth, so the pool passes and cap = 0.25 ETH.","severity":"low","snippet":"        wethSide = Math.min(IERC20Balance(weth).balanceOf(pool), token < weth ? virtual1 : virtual0);","title":"v3 depth check accepts WETH that sits out of range: MIN_LIQUIDITY and the regular payout cap can be met by a narrow in-range position plus parked balances"}],"hash":"c7d5674022f30cb8c5a24697592cb8248b6db70d1fe83ed44788dfb9bc0ff5b2","nodeId":"79587ec2-b399-4ac6-9ea2-caafd34c7bf1","outcome":"completed","summary":"Review complete. The findings file holds two substantiated findings, one with a failing Foundry proof.\n\n**Builds and checks (both profiles)**\n\n| Check | Result |\n|---|---|\n| Launch profile build and test (via_ir off, bytecode_hash none) | 160 passed, 2 fork tests skipped |\n| Protected profile build and test (via_ir on, whole tree) | 160 passed, 2 skipped. The prior report's OOM did not recur on this 24 GB machine |\n| testPinnedGachaRuntimeCodeHash under launch profile | passes, hash 0x2db114…c4cd matches |\n| Deployed GoblinNFT at 0xC5d5… | runtime code hash equals the launch-profile build of src/GoblinNFT.sol. On mainnet targetsSet is still false, 11 tokens live, not sold out |\n\n**85083c05 fixes re-verified by reading and tests.** The bind preconditions, the POL positive-delta settlement, the partial-fill rule including the exact-output sell check against amount plus fee, the Math.min genesis quote, the 4-hour cap valuation, the 9475 floor, the gross exact-output fee, and the 24-hour cancel all hold. The cancel path makes no oracle call, so Curator pauses cannot block it, nor draw or settlement.\n\n**Finding 1, high, with proof.** The genesis depth bound from fix 4 converts active liquidity to ETH as if every position were full range. A position one tick spacing wide contributes the same liquidity with about 0.3 percent of the ETH. The 30-minute recording rule only delays this. In the proof at test/scratch/ConcentratedDepth.t.sol, a position holding about 1 ETH raises the payout cap from 5.0 ETH to 21.65 ETH while the pool holds 101 ETH, and a 0.3 ETH position passes the 1 ETH minimum on a pool holding 0.8 ETH. Both tests fail on the current code. The proposed fix bounds depth by the full-range positions of trusted owners read through extsload, or by walking ticks, without changing the economics.\n\n**Finding 2, low.** The v3 pool path bounds depth by the pool's WETH balance, which counts out-of-range positions, so the 5 ETH floor and the 0.25 ETH cap are satisfiable with parked balances plus a narrow in-range position.\n\n**Not verified.** The IMD manifest schema is not in this tree, so I could not confirm whether a $contract:GoldHook remainder target is accepted. Path B's contract logic is sound: GOLD leaves the hook only through bindGacha to the matching vault. Path A depends on the paying wallet transferring exactly genesisSupply. This stays an open item for IMD.\n\n**Trust assumptions to note.** The NFT owner can re-run setLaunchTargets after bindGacha but before release, sending the mint escrow elsewhere while hook fees still flow to the bound gacha. The inherent cost of a multi-hour GOLD TWAP push at the 1 ETH minimum depth remains an accepted design risk, bounded by the per-draw caps only when finding 1 is fixed.","treeHash":null,"usage":{"cachedInputTokens":1506722,"inputTokens":546,"model":"claude-fable-5-1","outputTokens":43281,"runtime":"claude","turns":25,"wallClockMs":1151615}}],"verification":[]}