{"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":"ca28d248-399e-4f07-b77f-2ef35593c3bd","kind":"audit","nodes":[{"acceptedSubmissionHash":"be3fa69818364fd15d60591f401925b8a0c361339a8b4cc91c4fd1c9476b200b","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":"cc8f23b680f732d1e62226cdf5c62e1e59e98a254dbfae4f419087382f5e2a96","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":"1bda366afbb9555d0558436367abb2e88c01a11d73cf1dad8b6d85eb8176be54","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"4bc752b6b144683df9c9aa3c2da31af3f38fa70411e3bda2f9c63324dd3001f0","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":"0769f4d137bbc850b79033e3f018baf8a173f4584d54c82b9224542c44bceb67","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":"Courier ($STAMP), re-check of IMD Swarm audit ea514609 (that audit read commit 0a2ce30; this is commit 312f6a9). Read AUDIT.md first: section 6 maps every finding to its fix and the test that reproduces it, and section 1 describes the system as it is now.\n\nWhat it is: a game on Robinhood Chain (4663). Players put Courier NFTs on duty at post offices and earn $STAMP (21M cap), which trades in one Uniswap v4 pool against IMD; the hook takes 4% of the IMD side of every swap, rounded up, all to the protocol, and locks a 2.1M single-sided launch allocation forever. It launches in two stages from one wallet: stage 1 deploys the NFT for the mint; stage 2, only after the reveal, deploys the token, the pool and the post office, links them, and renounces every owner, so nothing has an owner afterwards.\n\nScope: contracts/src/StampHook.sol, StampRouter.sol, StampEthRouter.sol, StampToken.sol, PostOffice.sol, CourierNFT.sol, lib/SafeTransfer.sol, contracts/script/DeployMainnet.s.sol, DeployCouriers.s.sol, DeployLib.sol. Out of scope: the view-only art (CourierRenderer, CourierSVG, CourierTraits), the dev scripts (Deploy.s.sol, DeployFork.s.sol) and web/.\n\nWhat changed, and what to look at hardest:\n1. PostOffice has no owner (costs, rates and tiers fixed) and refuses to deploy before the couriers are revealed; reward debt is now kept unscaled (power x accRewardPerPower) so each stretch rounds down once. Check that total minted can never exceed totalEmitted, under any order of assign, unassign, levelUp on duty and claims, and that the new claimable() view matches claim().\n2. Routers stop sells at the launch price (StampHook.sellPriceLimit); price() reports the launch price when the pool sits beyond it. Check both $STAMP/IMD orderings, partial sells, the ETH router's sell path, and that nothing else changed in the fee or settlement.\n3. The fee rounds up (mulDivRoundingUp) in beforeSwap and afterSwap. Check the PartialFill accounting still holds in all four modes and that no trader pays more than 1 wei over 4%.\n4. Start tick bounded to +-400,000 and launch to 21M: check that every accepted constructor input opens.\n5. CourierNFT: Ownable2Step; renounceOwnership reverts until the couriers are revealed and the game is set; mint payments go straight to the treasury; reveal ends the sale itself. PostOffice office payments go straight to the treasury.\n6. The deploy scripts: stage 2 must leave no owner on StampToken, StampHook, CourierRenderer or CourierNFT (it logs each), and must not be runnable before the reveal or from another wallet.\n\nTests: cd contracts; git submodule update --init --recursive; forge test (86 tests; the hook suite runs with IMD as currency0 and as currency1). Fork: forge test --match-contract StampHookForkTest --fork-url https://robinhood.drpc.org. Both launch stages against a fork with the real settings: ./script/deploy-mainnet.sh rehearse.","parentJobId":null,"planHash":"9d0d304c7a0bff0c46e47e46b51f2e3b97d7059837ff67227fa0e88539df1080","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"ca28d248-399e-4f07-b77f-2ef35593c3bd","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":"50958","feedbackHash":"e04c6d82c78500732e1139b58e54c068d0aebb9707f694fe6a24f07553ac4548","nodeKey":"audit_economics","submissionHash":"be3fa69818364fd15d60591f401925b8a0c361339a8b4cc91c4fd1c9476b200b","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52193","feedbackHash":"2531492c074400b37894f401af662e94bdd96ab1338916b3e0c0d66766327d7b","nodeKey":"audit_flow","submissionHash":"cc8f23b680f732d1e62226cdf5c62e1e59e98a254dbfae4f419087382f5e2a96","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52166","feedbackHash":"35b678f68dca62f23585ceb11099ef969dbc9449ac02ddb046f4487b9637933f","nodeKey":"audit_judge","submissionHash":"1bda366afbb9555d0558436367abb2e88c01a11d73cf1dad8b6d85eb8176be54","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52215","feedbackHash":"ce3ec8f99476d02b1e80dcb34ff5951090f08f3a0666af3fbe4b5a2b8eb8a50f","nodeKey":"audit_math","submissionHash":"4bc752b6b144683df9c9aa3c2da31af3f38fa70411e3bda2f9c63324dd3001f0","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52220","feedbackHash":"c37eaa38b441dd8d436c81e38419736ab3e5f0627beb4eeea9277d27d8586022","nodeKey":"audit_permissions","submissionHash":"0769f4d137bbc850b79033e3f018baf8a173f4584d54c82b9224542c44bceb67","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"3d35143247a2866356d401c0efd27f08f522de8fa40da7c34fcedce346763ddc","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"4ca9ed4f0937da89","findings":[{"citation":"resolved","description":"AUDIT.md guarantee 3 and fix #11 say every start price the constructor accepts opens. The constructor allows any launchSupply_ above 1e9 wei. _addLaunchLiquidity rounds the position's liquidity down. At |startTick| = 400,000 the range spans a price factor of e^20 (about 4.85e8), so any (launchSupply - 1e9) below about 4.85e8 wei gives liquidity 0. PoolManager.modifyLiquidity then reverts CannotUpdateEmptyPosition. This happens with IMD as currency0 and as currency1. openPool can never succeed for that hook, and since renounceOwnership needs a launched token, the hook is unusable and has to be redeployed. test_StartPriceIsBoundedSoOpenPoolAlwaysWorks only tries 2.1M and 21M as allocations, so the lower end of the accepted range is never tested. The real launch.env (2.1M) is not affected; this is a gap in the constructor's validation and in the stated guarantee. Fix: require a minimum launchSupply_ that gives non-zero liquidity at the worst-case tick (for example launchSupply_ >= LIQUIDITY_BUFFER + 1e18), or compute the liquidity in the constructor and revert if it is 0, and add the minimum allocation to the corner test.","line":144,"path":"contracts/src/StampHook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\nimport \"forge-std/Test.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\nimport {IPoolManager} from \"v4-core/src/interfaces/IPoolManager.sol\";\nimport {StampHook} from \"src/StampHook.sol\";\nimport {StampToken} from \"src/StampToken.sol\";\nimport {DeployLib} from \"script/DeployLib.sol\";\n\ncontract ImdStub {\n    function balanceOf(address) external pure returns (uint256) { return 0; }\n}\n\n/// The constructor accepts startTick = 400_000 with any launchSupply in (1e9, 21M], but openPool reverts\n/// for allocations up to ~1.48e9 wei: the liquidity floors to 0 and v4 rejects an empty position.\ncontract OpenPoolSmallLaunchTest is Test {\n    PoolManager pm;\n    address owner = makeAddr(\"owner\");\n\n    function _hook(address imd, int24 tick, uint256 launch) internal returns (StampHook h) {\n        bytes memory initCode = abi.encodePacked(\n            type(StampHook).creationCode,\n            abi.encode(pm, imd, owner, owner, tick, launch, StampHook.ImdEthPool(10_000, 100, address(0)))\n        );\n        (bytes32 salt,) = DeployLib.mineSalt(address(this), uint160(0x28CC), initCode, 0);\n        address a;\n        assembly { a := create2(0, add(initCode, 0x20), mload(initCode), salt) }\n        require(a != address(0), \"deploy\");\n        h = StampHook(a);\n    }\n\n    function _open(address imdAddr) internal {\n        vm.etch(imdAddr, address(new ImdStub()).code);\n        uint256 launch = 1e9 + 4e8; // accepted: > LIQUIDITY_BUFFER (1e9) and <= 21M\n        StampHook h = _hook(imdAddr, 400_000, launch); // accepted: |tick| <= 400_000, multiple of 200\n        StampToken s = new StampToken(owner, address(h), launch);\n        vm.prank(owner);\n        h.openPool(address(s)); // guarantee: every accepted constructor input opens\n        assertEq(h.token(), address(s));\n    }\n\n    function setUp() public {\n        pm = new PoolManager(address(this));\n    }\n\n    function test_SmallLaunchAtTopTickOpens_ImdFirst() public {\n        _open(address(0x1000));\n    }\n\n    function test_SmallLaunchAtTopTickOpens_ImdSecond() public {\n        _open(address(uint160(type(uint160).max) - 0x1000));\n    }\n}","reproduction":"Deploy StampHook (mined 0x28CC address) with startTick_ = 400000 and launchSupply_ = 1_400_000_000 (1.4e9 wei). Both pass the constructor checks. Deploy StampToken(owner, hook, 1.4e9), then call hook.openPool(token) as owner. Expected: the pool opens. Actual: it reverts CannotUpdateEmptyPosition() from PoolManager.modifyLiquidity, because liquidity = floor(4e8 * Q96 / (sqrtU - sqrtL)) = 0. Same result with IMD at a high address (currency1). The proof test fails on both orderings today.","severity":"low","snippet":"        if (launchSupply_ <= LIQUIDITY_BUFFER || launchSupply_ > MAX_LAUNCH_SUPPLY) revert BadToken();","title":"Constructor accepts small launch allocations that make openPool revert (CannotUpdateEmptyPosition) near the tick bound"},{"citation":"resolved","description":"_deployGame calls setOffice and renounceOwnership on c.renderer, which comes from deployments/robinhood-couriers.json or the RENDERER env var. It then calls CourierNFT.freezeRenderer(), which freezes whatever CourierNFT.renderer() is at that moment. Nothing checks that the two are the same contract. Between stage 1 and stage 2 the NFT owner may call setRenderer (an admin power listed in AUDIT.md section 4), for example to fix the art. If they do, stage 2 renounces the old, detached renderer and freezes the new one. The frozen renderer's owner stays the deployer, and its office is never set, so tokenURI never shows level or ON DUTY. _log prints CourierRenderer(c.renderer).owner(), so it reports 0x0 and the operator never sees the problem. This breaks the brief's requirement that stage 2 leave no owner on CourierRenderer, and guarantee 8 (no contract has an owner after launch). The same happens if RENDERER is set to the wrong address while the deployer also owns that contract. Fix: in _deployGame, use the NFT's renderer: require(address(CourierNFT(c.nft).renderer()) == c.renderer), or set c.renderer = address(CourierNFT(c.nft).renderer()) before linking. Have _log read the owner of the NFT's renderer too.","line":118,"path":"contracts/script/DeployMainnet.s.sol","reproduction":"Stage 1: DeployCouriers writes renderer R1 to robinhood-couriers.json. The owner deploys R2 = new CourierRenderer(nft, svg, deployer) and calls nft.setRenderer(R2), then mints and reveals. Stage 2: DeployMainnet runs without reverting, calling R1.setOffice(office), R1.renounceOwnership() and nft.freezeRenderer(), and logs 'Owner: Renderer 0x0'. Expected: the frozen renderer R2 has owner 0 and office set. Actual: R2.owner() == deployer and R2.office() == 0 forever, and since nft.rendererFrozen() is true the NFT can't move to a correctly linked renderer.","severity":"low","snippet":"        CourierRenderer(c.renderer).setOffice(ICourierDuty(address(office)));","title":"Stage-2 deploy links, renounces and logs the renderer from the JSON/env, not the NFT's actual renderer, so a swapped renderer keeps its owner and stays unlinked"}],"hash":"0769f4d137bbc850b79033e3f018baf8a173f4584d54c82b9224542c44bceb67","nodeId":"7291886e-5b63-4d26-a34d-425ea96b30f0","outcome":"completed","summary":"I found two low-severity defects in commit 312f6a9, and nothing at medium or above. Both are in `.imd-findings.json`. The project's own suite passes (86 of 86). I didn't run the fork test or the `rehearse` stage-2 run.\n\n1. **Some accepted constructor inputs can't open the pool** (`contracts/src/StampHook.sol:144`). The constructor accepts any launch allocation above 1e9 wei. With the start tick at ±400,000 and an allocation up to about 1.485e9 wei, the pool's starting liquidity rounds down to 0. `openPool` then reverts with `CannotUpdateEmptyPosition`, with IMD as currency0 or currency1. So \"every accepted constructor input opens\" (fix #11) doesn't hold at the low end. The existing corner test only tries 2.1M and 21M as allocations. Your real 2.1M launch isn't affected. I wrote a Foundry test for this and it fails on both orderings today; its source is attached as the proof. The fix is to require a minimum allocation, or to revert in the constructor if the liquidity works out to zero.\n2. **Stage 2 can leave the live renderer with an owner** (`contracts/script/DeployMainnet.s.sol:118`). The script links and renounces the renderer named in the deployments JSON or the `RENDERER` env var, but it freezes whatever renderer the NFT currently uses. Nothing checks they're the same. If the owner calls `setRenderer` between the two stages, the frozen renderer keeps the deployer as owner and is never linked to the post office, so the art never shows level or duty. The log reports the old renderer's owner as 0x0, so the mismatch wouldn't be noticed. This breaks the \"no owner after stage 2\" requirement. The fix is to require, or switch to, `CourierNFT.renderer()` in `_deployGame` and in the log.\n\nWhat I checked that held:\n- **Reward accounting:** total minted can't exceed `totalEmitted` under any order of assign, unassign, on-duty `levelUp` and claims. Power stays balanced across those actions, and `claimable()` uses the same accumulator, cap and referral cut as `claim()`.\n- **Fee:** the PartialFill check and the fee-rounding math hold in all four modes, within 1 wei of 4%.\n- **Sell limit:** the launch-price stop and the `price()` clamp are correct for both orderings, including partial sells and the ETH router's sell path.\n- **Ownership:** two-step transfer and renounce guards work on both the NFT and the hook.\n- **Hook guards:** pool creation and outside liquidity are blocked for everyone except the hook.\n- **Stage-2 deploy:** the script checks the reveal and the deploying wallet.\n\nTwo owner powers before launch, which I treat as trust assumptions rather than defects: the NFT owner could link the game to any address, which could then lock any courier, and the token's ownership is single-step and can be renounced before `setMinter`. The deploy script sequences both correctly.\n\nI deleted the scratch test afterwards, so the repository is unchanged apart from the findings file.","treeHash":null,"usage":{"cachedInputTokens":1112668,"inputTokens":28,"model":"claude-opus-5-5","outputTokens":18512,"runtime":"claude","turns":20,"wallClockMs":273834}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"7f1dec5ffcbde1d8","findings":[{"citation":"resolved","description":"AUDIT.md section 1, guarantee 3 and the resolution of finding 11 state that every (start tick, launch allocation) the constructor accepts leads to a working openPool. The constructor bounds |startTick_| <= 400,000 and launchSupply_ <= 21M, but its lower bound only requires launchSupply_ > LIQUIDITY_BUFFER (1e9 wei). _addLaunchLiquidity (lines 214-217) computes the position's liquidity from launchSupply - 1e9 with floor division. At startTick = +400,000 the price factor across the launch range is about e^20 (4.85e8): with IMD as currency0 the range is [minUsableTick, 400000] and liquidity = amount * Q96 / (sqrtU - sqrtL) ~= amount / 4.85e8; with IMD as currency1 the pool tick is -400,000, the range is [-400000, maxUsableTick] and liquidity ~= amount * sqrtL / Q96 ~= amount * 2.06e-9. For any accepted allocation below about 1e9 + 4.85e8 wei the liquidity floors to 0, PoolManager.modifyLiquidity reverts CannotUpdateEmptyPosition, and openPool can never succeed for that hook. Because renounceOwnership requires a launched token, the hook also cannot be renounced; the allocation minted to it by StampToken is unrecoverable and the hook has to be redeployed. test_StartPriceIsBoundedSoOpenPoolAlwaysWorks only tries 2.1M and 21M as its 'smallest and largest' allocations, so the low end of the accepted range is untested. The real launch (2.1M $STAMP, launch.env) is unaffected, and a failing openPool is caught in the script simulation before broadcast, which keeps this low. This merges the three specialist reports (audit_math, audit_economics, audit_permissions), which describe the same mechanism at the same line. Fix: make the constructor's lower bound match what opens, for example require launchSupply_ >= LIQUIDITY_BUFFER + 1e18 (opens at every tick in range for both orderings), or compute the launch liquidity in the constructor and revert when it is 0; then add the minimum allocation to the corner test.","line":144,"path":"contracts/src/StampHook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\nimport {StampHook} from \"src/StampHook.sol\";\nimport {StampToken} from \"src/StampToken.sol\";\n\ncontract ImdStub {\n    function balanceOf(address) external pure returns (uint256) {\n        return 0;\n    }\n}\n\n/// AUDIT.md guarantee 3 / finding 11: every (startTick, launchSupply) the StampHook constructor accepts must open.\n/// Today the constructor accepts startTick = 400_000 with launchSupply = 1.4e9 wei, and openPool reverts\n/// CannotUpdateEmptyPosition because the launch liquidity floors to 0. The test passes once the constructor\n/// rejects such inputs (a higher minimum, or a liquidity check) or openPool handles them.\ncontract AcceptedLaunchOpensTest is Test {\n    PoolManager pm;\n    address owner = makeAddr(\"owner\");\n\n    function setUp() public {\n        pm = new PoolManager(address(this));\n    }\n\n    /// Deploys the hook at a mined 0x28CC address. Returns address(0) if the constructor rejects the inputs.\n    function _hook(address imd, int24 tick, uint256 launch) internal returns (address h) {\n        bytes memory initCode = abi.encodePacked(\n            type(StampHook).creationCode,\n            abi.encode(pm, imd, owner, owner, tick, launch, StampHook.ImdEthPool(10_000, 100, address(0)))\n        );\n        bytes32 hash = keccak256(initCode);\n        bytes32 salt;\n        for (uint256 i;; i++) {\n            address a = address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), bytes32(i), hash)))));\n            if (uint160(a) & 0x3FFF == 0x28CC) {\n                salt = bytes32(i);\n                break;\n            }\n        }\n        assembly {\n            h := create2(0, add(initCode, 0x20), mload(initCode), salt)\n        }\n    }\n\n    function _check(address imdAddr, int24 tick, uint256 launch) internal {\n        vm.etch(imdAddr, address(new ImdStub()).code);\n        address h = _hook(imdAddr, tick, launch);\n        if (h == address(0)) return; // rejected by the constructor: nothing to open\n        StampToken s = new StampToken(owner, h, launch);\n        vm.prank(owner);\n        StampHook(h).openPool(address(s)); // accepted by the constructor, so this must work\n        assertEq(StampHook(h).token(), address(s));\n        assertEq(s.balanceOf(h), 0);\n    }\n\n    function test_AcceptedSmallLaunchAtTopTickOpens_ImdFirst() public {\n        _check(address(0x1000), 400_000, 1_400_000_000);\n    }\n\n    function test_AcceptedSmallLaunchAtTopTickOpens_ImdSecond() public {\n        _check(address(uint160(type(uint160).max) - 0x1000), 400_000, 1_400_000_000);\n    }\n\n    function test_AcceptedSmallestLaunchAtTopTickOpens_ImdFirst() public {\n        _check(address(0x1000), 400_000, 1_000_000_001);\n    }\n}","reproduction":"Deploy StampHook at a mined 0x28CC address with startTick_ = 400_000 and launchSupply_ = 1_400_000_000 wei (both pass the constructor: 400_000 % 200 == 0, |tick| <= 400_000, 1e9 < 1.4e9 <= 21M). Deploy StampToken(owner, hook, 1_400_000_000) so the hook holds the allocation, then call hook.openPool(token) as owner. Expected: the pool opens (the constructor accepted the inputs, AUDIT.md guarantee 3). Actual: liquidity = floor(4e8 * Q96 / (sqrtPriceAtTick(400000) - sqrtPriceAtTick(-887200))) = 0 with IMD as currency0, and floor(4e8 * (sqrtL*sqrtU/Q96) / (sqrtU - sqrtL)) = 0 with IMD as currency1; PoolManager.modifyLiquidity reverts CannotUpdateEmptyPosition() in both orderings. Same for launchSupply_ = 1e9 + 1. Verified: the three specialist proofs all fail with CannotUpdateEmptyPosition on this code (5 of their 6 cases; the one passing case used tick -400_000 with IMD as currency1, which is the opposite, liquidity-rich corner). The attached proof fails in all three cases on this code and passes once the constructor rejects the inputs (checked locally with a temporary minimum of LIQUIDITY_BUFFER + 1e18, then reverted).","severity":"low","snippet":"        if (launchSupply_ <= LIQUIDITY_BUFFER || launchSupply_ > MAX_LAUNCH_SUPPLY) revert BadToken();","title":"StampHook constructor accepts launch allocations below ~1.49e9 wei at |startTick| = 400,000, for which openPool always reverts (CannotUpdateEmptyPosition)"},{"citation":"resolved","description":"_deployGame calls setOffice and renounceOwnership on c.renderer, which comes from deployments/robinhood-couriers.json (written by stage 1) or the RENDERER env var, and then calls CourierNFT.freezeRenderer(), which freezes whatever CourierNFT.renderer() is at that moment. Nothing checks that the two are the same contract. AUDIT.md section 4 lists setRenderer (until frozen) as an admin power the NFT owner keeps between stage 1 and stage 2, e.g. to fix the art. If the owner uses it and does not also edit the JSON, stage 2 runs to completion: the detached renderer R1 gets the office link and is renounced, while the renderer R2 the NFT is now frozen on keeps the deployer as owner and has office == 0, so tokenURI never shows level or ON DUTY. _log prints CourierRenderer(c.renderer).owner(), i.e. R1's, so it reports 0x0 and the operator sees nothing wrong. This breaks the brief's requirement that stage 2 leave no owner on CourierRenderer and guarantee 8 (no contract has an owner after launch); R2's owner can still call setOffice once, which is the only remaining power (and R2 is used by a frozen, ownerless NFT). The precondition is an operator action (a renderer swap without updating the JSON), so this is low. Fix: in _deployGame derive the renderer from the NFT, c.renderer = address(CourierNFT(c.nft).renderer()), or require(address(CourierNFT(c.nft).renderer()) == c.renderer, ...) before linking, and have _log read the owner of CourierNFT(c.nft).renderer().","line":118,"path":"contracts/script/DeployMainnet.s.sol","reproduction":"Verified with contracts/test/scratch/DeployRenderer.t.sol, which inherits CourierDeployer and runs the real _deployGame with a PoolManager and a stub IMD deployed at the script's hard-coded mainnet addresses. Stage 1: c = _deployCouriers(deployer, deployer, 0.003 ether, commit) gives NFT N and renderer R1. Between stages the owner deploys R2 = new CourierRenderer(N, new CourierSVG(), deployer), calls N.setRenderer(R2), then N.reveal(secret). Stage 2: _deployGame(deployer, deployer, deployer, 3000e18, 2_100_000e18, 0.005 ether, 1100, c) with c.renderer still R1 does not revert. Expected: the renderer the NFT is frozen on has owner 0 and office == PostOffice. Actual: N.rendererFrozen() == true, N.renderer() == R2, R1.owner() == 0 (what _log prints as 'Owner: Renderer'), but R2.owner() == deployer (0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496 in the test) and R2.office() == 0. Test output: '[FAIL: the live renderer should be renounced: 0x7FA9...1496 != 0x0000...0000]'.","severity":"low","snippet":"        CourierRenderer(c.renderer).setOffice(ICourierDuty(address(office)));\n        CourierRenderer(c.renderer).renounceOwnership();","title":"Stage 2 links, renounces and logs the renderer recorded in robinhood-couriers.json / RENDERER, not the renderer the NFT actually uses, so a renderer swapped between the stages keeps its owner and neve"},{"citation":"resolved","description":"The constructor couples the two destinations: it stores treasury for mint payments and calls _setDefaultRoyalty(treasury_, 500) for secondary-sale royalties. setTreasury only updates the first. An owner who changes the treasury before stage 2 (the only window in which the setter works, since the game deploy renounces the NFT) ends up with mint ETH going to the new address while marketplaces keep paying royalties to the old one unless they also remember to call setRoyalty. No funds are lost and no third party can trigger it; it is an asymmetry between the constructor and its setter, pre-launch only. Fix: have setTreasury also call _setDefaultRoyalty(treasury_, <current bps>), or document that setRoyalty must be called alongside it. (Reported by audit_flow; reproduced.)","line":192,"path":"contracts/src/CourierNFT.sol","reproduction":"Deploy CourierNFT(owner, T1, 0.003 ether, commit); owner calls setSaleOpen(true) and setTreasury(T2); alice calls mint{value: 0.003 ether}(1); then royaltyInfo(1, 1 ether). Expected: mint ETH and royalties both go to T2, or a documented need to call setRoyalty. Actual: T2.balance == 0.003 ether while royaltyInfo returns (T1, 0.05 ether). Verified in contracts/test/scratch/NftAdmin.t.sol::test_SetTreasuryLeavesRoyaltyReceiverBehind (passes, asserting the mismatch).","severity":"info","snippet":"    function setTreasury(address treasury_) external onlyOwner {\n        if (treasury_ == address(0)) revert ZeroAddress();\n        treasury = treasury_;\n    }","title":"CourierNFT.setTreasury moves mint revenue but leaves the ERC-2981 royalty receiver on the old treasury"},{"citation":"resolved","description":"The guard added for audit finding 4 checks the reveal and the game link but not the renderer freeze. If the owner renounces without first calling freezeRenderer(), nobody can call setRenderer or freezeRenderer any more (both onlyOwner), so the art is in fact final, but the public rendererFrozen() flag that AUDIT.md section 1 presents as the signal that 'the art can never be changed again' reads false permanently. The mainnet script (_deployGame) calls freezeRenderer() immediately before renounceOwnership(), so the shipped flow is correct; the guard just does not enforce the invariant the flag advertises, and a manual or partial stage 2 could leave it unset. Fix: add `|| !rendererFrozen` to the NotFinished check, or set rendererFrozen inside renounceOwnership. (Reported by audit_flow; reproduced.)","line":174,"path":"contracts/src/CourierNFT.sol","reproduction":"Owner: reveal(secret); setGame(office); renounceOwnership() without freezeRenderer(). Expected: NotFinished, or the renderer frozen as part of renouncing. Actual: owner() == address(0), rendererFrozen() == false, and both freezeRenderer() and setRenderer() now revert OwnableUnauthorizedAccount for everyone. Verified in contracts/test/scratch/NftAdmin.t.sol::test_RenounceWithoutFreezeLeavesFlagFalse (passes, asserting the stuck flag).","severity":"info","snippet":"        if (seed == 0 || game == address(0)) revert NotFinished();","title":"CourierNFT.renounceOwnership does not require the renderer to be frozen, so rendererFrozen can read false forever on an ownerless collection"}],"hash":"1bda366afbb9555d0558436367abb2e88c01a11d73cf1dad8b6d85eb8176be54","nodeId":"75c19931-0f33-45a5-9af2-467447d4c0a4","outcome":"completed","summary":"Review complete. The findings file at the repository root holds four substantiated findings, all reproduced against commit 312f6a9, and the source tree is unchanged.\n\n**What I checked**\n\n- Full suite: 86 tests pass. Fork suite against Robinhood Chain passes. The two-stage rehearsal against a fork runs end to end and logs every owner as zero.\n- I traced the areas the brief flags hardest: reward-debt accounting (sum of floored per-office stretches is bounded by `totalEmitted`, and `claimable` mirrors `claim`), the sell price limit and `price()` clamp in both currency orderings, the rounded-up fee and PartialFill arithmetic in all four modes (fee is never under 4% and at most 0.96 wei over), the ETH router's refund and settlement paths, and the stage-2 ordering and wallet check. I found no new defects beyond what the specialists reported.\n\n**Findings kept**\n\n1. **Low, merged from three specialists.** `StampHook` constructor at `contracts/src/StampHook.sol:144` accepts launch allocations down to 1e9 + 1 wei, but at a start tick of 400,000 the launch liquidity floors to zero in both orderings and `openPool` reverts, so the stated guarantee that every accepted input opens does not hold. All three specialist proofs fail here for that reason. I attached my own proof instead, written so it passes under either natural fix. I confirmed it fails now and passes with a temporary minimum, then restored the file.\n2. **Low.** Stage 2 at `contracts/script/DeployMainnet.s.sol:118` links and renounces the renderer from the JSON rather than the NFT's live renderer. A scratch test driving the real stage-2 function after a `setRenderer` swap shows the live renderer keeps the deployer as owner with no office set, while the log reports zero.\n3. **Info.** `setTreasury` leaves the ERC-2981 royalty receiver on the old treasury. Reproduced.\n4. **Info.** `renounceOwnership` does not require `freezeRenderer`, so the flag can read false forever on an ownerless collection. Reproduced.\n\n**Dropped or merged.** Nothing was dropped; the three launch-size reports were duplicates of one mechanism and are now one finding.\n\n**Scratch material.** My tests live under `contracts/test/scratch/` only; nothing else in the tree changed and no configuration file was touched.","treeHash":null,"usage":{"cachedInputTokens":1331324,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":34721,"runtime":"claude","turns":36,"wallClockMs":586560}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"e2a4a53638df3fc6","findings":[{"citation":"resolved","description":"AUDIT.md section 1 and guarantee 3 (and brief item 4) say every start price and launch the constructor accepts opens. The constructor only requires launchSupply_ > LIQUIDITY_BUFFER (1e9) and |startTick| <= 400000. _addLaunchLiquidity computes liquidity from (launchSupply - 1e9) with floor division. At startTick = +400000 the factor is about 1.0001^200000 ≈ 4.85e8: with IMD as currency0 the range is [minTick, 400000] and L = amount*Q96/(sqrtU - sqrtL); with IMD as currency1 the range is [-400000, maxTick] and L ≈ amount * 2.06e-9. So any launchSupply in (1e9, ~1.485e9) gives liquidity 0. PoolManager.modifyLiquidity then reverts CannotUpdateEmptyPosition, and openPool can never succeed for that hook. The existing test test_StartPriceIsBoundedSoOpenPoolAlwaysWorks only tries 2.1M and 21M as launch amounts, so it misses this. The impact is limited: launch.env uses 2.1M, and the deploy reverts atomically inside the stage-2 broadcast. Fix: make the constructor bound match what opens. Either raise the minimum launch, e.g. require launchSupply_ >= LIQUIDITY_BUFFER + 1e18 (it opens at every tick in range), or compute the liquidity in the constructor and require it to be nonzero.","line":144,"path":"contracts/src/StampHook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\nimport {StampHook} from \"src/StampHook.sol\";\nimport {StampToken} from \"src/StampToken.sol\";\n\ncontract MockIMD2 {\n    mapping(address => uint256) public balanceOf;\n}\n\n/// A (startTick, launchSupply) pair the constructor accepts must open (AUDIT.md guarantee 3).\ncontract OpenPoolSmallLaunchTest is Test {\n    PoolManager pm;\n\n    function _open(address imdAt, int24 startTick, uint256 launch) internal {\n        vm.etch(imdAt, address(new MockIMD2()).code);\n        bytes memory initCode = abi.encodePacked(\n            type(StampHook).creationCode,\n            abi.encode(pm, imdAt, address(this), address(this), startTick, launch, StampHook.ImdEthPool(10_000, 100, address(0)))\n        );\n        address h;\n        for (uint256 i; ; i++) {\n            address a = address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), address(this), bytes32(i), keccak256(initCode))))));\n            if (uint160(a) & 0x3FFF == 0x28CC) {\n                bytes32 salt = bytes32(i);\n                assembly { h := create2(0, add(initCode, 0x20), mload(initCode), salt) }\n                break;\n            }\n        }\n        require(h != address(0), \"hook\");\n        StampToken s = new StampToken(address(this), h, launch); // accepted by the constructor\n        StampHook(h).openPool(address(s)); // reverts: liquidity rounds to 0 -> CannotUpdateEmptyPosition\n        assertEq(s.balanceOf(h), 0);\n    }\n\n    function setUp() public {\n        pm = new PoolManager(address(this));\n    }\n\n    function test_ImdCurrency0_StartTick400000_SmallLaunchOpens() public {\n        _open(address(0x1000), 400_000, 1e9 + 1e8);\n    }\n\n    function test_ImdCurrency1_StartTick400000_SmallLaunchOpens() public {\n        _open(address(uint160(type(uint160).max) - 0x1000), 400_000, 1e9 + 1e8);\n    }\n}","reproduction":"Deploy the hook at a mined address with startTick = 400000 and launchSupply = 1.1e9 (accepted). Mint StampToken(owner, hook, 1.1e9), then call openPool(token). Expected: the pool opens (the constructor accepted the inputs). Actual: modifyLiquidity is called with liquidityDelta 0 and reverts CannotUpdateEmptyPosition(). This happens with IMD as currency0 (range [-887200, 400000]) and with IMD as currency1 (range [-400000, 887200]). The proof test fails in both cases.","severity":"low","snippet":"        if (launchSupply_ <= LIQUIDITY_BUFFER || launchSupply_ > MAX_LAUNCH_SUPPLY) revert BadToken();","title":"StampHook constructor accepts (startTick=+400000, small launchSupply) for which openPool always reverts (launch liquidity rounds to 0)"}],"hash":"4bc752b6b144683df9c9aa3c2da31af3f38fa70411e3bda2f9c63324dd3001f0","nodeId":"71016121-c5f2-4ae8-9c8c-25f4732acbf4","outcome":"completed","summary":"The math audit of commit 312f6a9 found one defect, rated low; the rest of the math holds. `.imd-findings.json` holds that one finding with a Foundry proof test, which I ran and saw fail on the current code.\n\n**Finding (low): some inputs the hook's constructor accepts can never open the pool** (`contracts/src/StampHook.sol:144`). The constructor only requires the launch amount to be above 1e9 wei. With a start tick of +400000 and a launch amount between 1e9 and about 1.485e9 wei, the launch liquidity rounds down to 0. `openPool` then always reverts with `CannotUpdateEmptyPosition()`, whichever side IMD is on. That breaks AUDIT.md guarantee 3 (\"every start price the constructor accepts opens\"). The existing test misses it because it only tries 2.1M and 21M as launch amounts. Real exposure is small: `launch.env` uses 2.1M, and a failed stage 2 reverts as a whole. The fix is to raise the minimum launch amount (e.g. buffer + 1e18), or to check in the constructor that the liquidity comes out nonzero.\n\n**What holds, per item in the brief:**\n1. **PostOffice rewards:** each accumulator step rounds down against the total power, so across all offices the payout for a stretch never exceeds what was emitted. Each office's payout for a stretch rounds down once more. Every power change (assign, unassign, levelUp on duty) checkpoints first, so total minted ≤ `totalEmitted` ≤ the schedule ≤ the cap, in any order of actions. `claimable()` gives the same result as `claim()`, including the cap clamp and the referral cut. A courier on duty is locked, so levelUp always credits the owner's own office. Known loss, as designed: emissions while nobody has power are never paid out.\n2. **Routers:** the sell price limit points the right way for both $STAMP/IMD orderings. A partial sell pulls only the tokens actually used, on both routers. A pool sitting past the launch price only blocks router sells until the next buy, which walks back through empty ticks at no cost. Nothing else in the fee or settlement changed.\n3. **Fee rounding:** I checked all four swap modes by hand. A swap where IMD is the amount the trader specifies must fill exactly or it reverts `PartialFill`; for the other two modes the fee is taken on what actually filled. The overcharge is always under 1 wei. Dust swaps pay the fee and are never undercharged.\n4. **Start tick and launch bounds:** at the extremes (tick ±400000, launch 21M) the position fits within the pool's per-tick liquidity limit. The only failing inputs are the low-launch cases above.\n5. **CourierNFT, PostOffice payments, and the deploy scripts (stage 2 must leave no owner):** they behave as the brief says. Stage 2 checks the reveal, PostOffice checks it again, and the run is tied to the wallet that owns the NFT.\n\n**Tests:** the local suite passes, 85 of 85. That's the brief's 86 minus the fork test, which I didn't run, and I didn't run the fork rehearsal either. I didn't write an invariant fuzz test for the reward accounting; that conclusion is from reading the code. I deleted the scratch test afterwards, so the working tree is unchanged.","treeHash":null,"usage":{"cachedInputTokens":1125195,"inputTokens":30,"model":"claude-opus-5-5","outputTokens":17585,"runtime":"claude","turns":16,"wallClockMs":288681}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"d20c1a95c50699ea","findings":[{"citation":"resolved","description":"AUDIT.md (section 1 and finding 11) says every input the constructor accepts leads to a working openPool. It bounds |startTick| <= 400,000 and launchSupply <= 21M, but the lower bound only requires launchSupply > LIQUIDITY_BUFFER (1e9 wei). When IMD is currency0, _addLaunchLiquidity computes liquidity = (launchSupply - 1e9) * Q96 / (sqrtU - sqrtL) (line 216), with sqrtU = sqrtPriceAtTick(startTick). For a high start tick, sqrtU/Q96 is about 1.0001^(startTick/2), which is about 4.85e8 at tick 400,000, so any allocation below 1e9 + 4.85e8 wei rounds liquidity down to 0. PoolManager.modifyLiquidity then reverts CannotUpdateEmptyPosition, so openPool can never succeed for that hook: the allocation is stuck and the hook cannot be renounced (renounceOwnership requires token != 0). test_StartPriceIsBoundedSoOpenPoolAlwaysWorks only tries LAUNCH = 2.1M and 21M as its 'smallest and largest' allocations, so this corner is never tested. The real launch settings (2.1M e18) are unaffected, which is why this is low. Fix options: raise the constructor's lower bound so that liquidity >= 1 at |startTick| = 400,000 for both orderings (for example launchSupply_ >= LIQUIDITY_BUFFER + 1e9, or a plain minimum such as 1e18), or compute the liquidity in the constructor and revert when it is 0. Then add the minimum allocation to the corner test.","line":144,"path":"contracts/src/StampHook.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {PoolManager} from \"v4-core/src/PoolManager.sol\";\nimport {StampHook} from \"src/StampHook.sol\";\nimport {StampToken} from \"src/StampToken.sol\";\n\ncontract MockIMD2 {\n    mapping(address => uint256) public balanceOf;\n}\n\ncontract SmallLaunchTest is Test {\n    function _open(int24 tick, uint256 launch, address imdAt, address hookAt) internal {\n        PoolManager pm = new PoolManager(address(this));\n        vm.etch(imdAt, address(new MockIMD2()).code);\n        deployCodeTo(\n            \"StampHook.sol:StampHook\",\n            abi.encode(pm, imdAt, address(this), address(this), tick, launch, StampHook.ImdEthPool(10_000, 100, address(0))),\n            hookAt\n        );\n        StampToken s = new StampToken(address(this), hookAt, launch);\n        // The constructor accepted (tick, launch); openPool must work for it.\n        StampHook(hookAt).openPool(address(s));\n        assertEq(StampHook(hookAt).token(), address(s));\n    }\n\n    // IMD as currency0 ($STAMP currency1), top of the tick bound, smallest accepted launch.\n    function test_SmallestAcceptedLaunchOpens_ImdFirst() public {\n        _open(400_000, 1e9 + 1, address(0x10), address(uint160(0xA0 << 152 | 0x1028CC)));\n    }\n\n    // IMD as currency1, bottom of the bound.\n    function test_SmallestAcceptedLaunchOpens_ImdSecond() public {\n        _open(-400_000, 1e9 + 1, address(type(uint160).max), address(uint160(0x1028CC)));\n    }\n}","reproduction":"Deploy StampHook with IMD as currency0 (IMD address below $STAMP), startTick = 400_000 and launchSupply = 1e9 + 1. Both pass the constructor checks (400_000 % 200 == 0, |tick| <= 400_000, 1e9 < launch <= 21M). Then mint the allocation to the hook with new StampToken(owner, hook, 1e9 + 1) and call openPool(stamp) as owner. Expected: the pool opens, as AUDIT.md finding 11 states. Actual: liquidity = 1 * 2^96 / (sqrtPriceAtTick(400000) - sqrtPriceAtTick(minUsableTick)) = 0, and openPool reverts CannotUpdateEmptyPosition() inside PoolManager.modifyLiquidity. Ran test/scratch/SmallLaunch.t.sol: test_SmallestAcceptedLaunchOpens_ImdFirst FAILs with CannotUpdateEmptyPosition. The mirrored case (IMD currency1, tick -400_000) passes.","severity":"low","snippet":"        if (launchSupply_ <= LIQUIDITY_BUFFER || launchSupply_ > MAX_LAUNCH_SUPPLY) revert BadToken();","title":"Constructor still accepts (startTick, launchSupply) pairs whose openPool reverts: re-check of finding 11 is incomplete for small launch allocations"}],"hash":"be3fa69818364fd15d60591f401925b8a0c361339a8b4cc91c4fd1c9476b200b","nodeId":"b07a1260-4c14-47ad-a997-8d55e8736500","outcome":"completed","summary":"I found one defect, rated low, and wrote it to `.imd-findings.json`. Your local suite passes: 86 of 86 under `forge test`. I didn't run the fork test or the `deploy-mainnet.sh rehearse` script.\n\n**Finding (low): `StampHook` still accepts some constructor inputs that make `openPool` fail** (`contracts/src/StampHook.sol:144`)\n- **Where it breaks:** The constructor's only lower limit on the launch amount is `> LIQUIDITY_BUFFER` (1e9 wei). With IMD as currency0 and a start tick near +400,000, the liquidity calculation at line 216 rounds down to 0 for any launch amount below about 1e9 + 4.85e8 wei. `PoolManager.modifyLiquidity` then reverts `CannotUpdateEmptyPosition`. That hook can never open its pool, so the allocation is stuck and the hook can't be renounced.\n- **Reproduction:** IMD as currency0, `startTick = 400_000`, `launchSupply = 1e9 + 1`. I ran a test that does this and it fails with `CannotUpdateEmptyPosition()`. The same case with the order reversed (IMD as currency1, tick -400,000) opens fine. The full test source is in the finding's `proof` field.\n- **Why the existing test misses it:** `test_StartPriceIsBoundedSoOpenPoolAlwaysWorks` only tries 2.1M and 21M as its \"smallest and largest\" amounts.\n- **Impact:** Your real settings (2.1M STAMP) are not affected.\n- **Fix:** Raise the lower limit (for example, buffer + 1e9, or simply 1e18), or revert in the constructor when the computed liquidity would be 0. Then add the minimum amount to the corner test.\n\n**Areas I checked and found sound:**\n1. **PostOffice rewards:**\n   - Total minted can't exceed `totalEmitted`. An office's power can only change after its rewards are banked, and each stretch rounds down once. So across all offices, each increase of the reward accumulator pays out at most the reward emitted for that stretch. This holds for any order of assign, unassign, levelUp on duty and claim.\n   - Level-up power deltas add up exactly to what unassign removes.\n   - `claimable()` uses the same accumulator maths, supply cap and referral cut as `claim()`.\n2. **Sell price limit:** Both routers stop sells at the launch price, in both IMD/STAMP orderings. Partial sells charge only the STAMP actually taken. On the ETH router's sell path, STAMP is paid in before the IMD→ETH leg. `price()` reports the launch price when the pool sits past it, in both orderings.\n3. **Fee:**\n   - The fill check in `afterSwap` holds in all four modes, because v4 pools fill exact-in and exact-out swaps completely unless a price limit stops them.\n   - In every mode the fee is at most 4% plus under 1 wei. The one quirk: an exact-in buy of 1 wei through a third-party router pays 1 wei of fee and receives nothing. That's still under 1 wei over 4%, and your own router rejects it.\n4. **Start tick and launch bounds:** Liquidity per tick stays under v4's limit at every corner. The only failing inputs are the small amounts in the finding above.\n5. **CourierNFT and treasury payments:**\n   - Ownership is two-step. Renouncing is blocked until the reveal and the game link, and it also clears any pending new owner.\n   - `reveal` ends the sale, and the sale can't reopen afterwards.\n   - Mint and office payments go straight to the treasury.\n   - Reveal grinding is already listed as accepted in AUDIT.md §5, so I didn't report it.\n6. **Deploy scripts:** Stage 2 refuses to run before the reveal and checks that the caller owns the NFT. It renounces the token, renderer, hook (after `openPool`) and NFT, and logs each owner. The reward rate works out to exactly 2.25 STAMP per block, which matches the constructor's cap check.\n\nI deleted the scratch test afterwards, and no repository files were changed.","treeHash":null,"usage":{"cachedInputTokens":1442174,"inputTokens":36,"model":"claude-opus-5-5","outputTokens":21611,"runtime":"claude","turns":19,"wallClockMs":360280}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"5d667e4b0751bcb5","findings":[{"citation":"resolved","description":"The constructor couples the two destinations: it stores `treasury` for mint payments and calls `_setDefaultRoyalty(treasury_, 500)` for secondary-sale royalties. The setter only updates the first. An owner who changes the treasury before stage 2 (the only window in which the setter works, since the game deploy renounces the NFT) ends up with mint ETH going to the new address while marketplaces keep paying royalties to the old one, unless they also remember to call `setRoyalty`. No funds are lost and no third party can trigger it; it is an asymmetry between a constructor and its setter, pre-launch only. Fix: have `setTreasury` also call `_setDefaultRoyalty(treasury_, <current bps>)`, or document that `setRoyalty` must be called alongside it.","line":192,"path":"contracts/src/CourierNFT.sol","reproduction":"Deploy CourierNFT(owner, T1, 0.003 ether, commit); owner calls setTreasury(T2); then royaltyInfo(1, 1 ether). Expected: (T2, 0.05 ether) or a documented need to call setRoyalty. Actual: (T1, 0.05 ether) while a subsequent mint{value: 0.003 ether}(1) pays T2. Verified in contracts/test/scratch/NftAdmin.t.sol::test_SetTreasuryLeavesRoyaltyReceiverBehind.","severity":"info","snippet":"    function setTreasury(address treasury_) external onlyOwner {\n        if (treasury_ == address(0)) revert ZeroAddress();\n        treasury = treasury_;\n    }","title":"CourierNFT.setTreasury moves mint revenue but leaves the ERC-2981 royalty receiver on the old treasury"},{"citation":"resolved","description":"The guard added for audit finding 4 checks the reveal and the game link but not the renderer freeze. If the owner renounces without first calling `freezeRenderer()`, nobody can call `setRenderer` any more (it is onlyOwner), so the art is in fact final, but the public `rendererFrozen()` flag that AUDIT.md section 1 presents as the signal that 'the art can never be changed again' reads false forever. The mainnet script (`_deployGame`) calls `freezeRenderer()` immediately before `renounceOwnership()`, so the shipped flow is correct; the guard just does not enforce the invariant the flag advertises. Fix: add `|| !rendererFrozen` to the NotFinished check, or freeze inside renounceOwnership.","line":173,"path":"contracts/src/CourierNFT.sol","reproduction":"Owner: reveal(secret); setGame(office); renounceOwnership() without freezeRenderer(). Expected: NotFinished (or the renderer frozen as part of renouncing). Actual: owner() == address(0) and rendererFrozen() == false permanently. Verified in contracts/test/scratch/NftAdmin.t.sol::test_RenounceWithoutFreezeLeavesFlagFalse.","severity":"info","snippet":"    function renounceOwnership() public override onlyOwner {\n        if (seed == 0 || game == address(0)) revert NotFinished();\n        super.renounceOwnership();\n    }","title":"CourierNFT.renounceOwnership does not require freezeRenderer, so rendererFrozen can stay false on an ownerless collection"}],"hash":"cc8f23b680f732d1e62226cdf5c62e1e59e98a254dbfae4f419087382f5e2a96","nodeId":"daeae46f-a079-41ba-88f6-be512abeec9a","outcome":"completed","summary":"The findings file is written and valid, snippet lines match the tree, and the only changes on disk are the findings file plus my scratch tests under `contracts/test/scratch/`. Nothing in scope was modified.\n\n## Review result: no exploitable defects found at commit 312f6a9\n\nI audited the nine in-scope files against the Pashov, x-ray, Trail of Bits entry-point and ethskills checklists, re-checked all twelve resolutions from audit ea514609, and reproduced the project's own checks: the 86-test suite passes, the fork test passes against live Robinhood Chain, and `deploy-mainnet.sh rehearse` runs both stages and logs a zero owner for StampToken, StampHook, CourierRenderer and CourierNFT. The CREATE2 factory the script relies on is deployed on chain 4663.\n\nBeyond the shipped tests, I wrote scratch fuzz suites (2048 runs, both IMD orderings) that confirm the brief's six focus points:\n\n- **Reward accounting.** Random sequences of open, assign, unassign, levelUp on duty, upgrade and claim across three referred players, with gaps crossing halvings, never mint more than `totalEmitted`, and every `claim()` mints exactly what `claimable()` reported for owner and referrer. The unscaled-debt proof holds analytically too: each stretch floors once, and the per-era accumulator is itself floored, so the rational sum is bounded by emissions.\n- **Fee.** In all four modes the fee is at least 4% and less than 4% plus 1 wei of the IMD side; the hook's ERC-6909 claims equal `pendingProtocolFees` exactly after every swap. An exact-out sell asking for more IMD than the pool holds reverts with PartialFill rather than overcharging.\n- **Sell price limit.** Router and ETH-router sells larger than the pool's IMD stop exactly at the launch sqrt price, charge only for the part sold, leave 1 wei of rounding dust, and `price()` keeps reporting the launch price. After a third-party router walks the price into the empty range, both routers' sells revert (there is no IMD to pay) and the next buy resumes from the launch price, as documented in section 5.\n- **Start tick bounds, CourierNFT ownership, and the two-stage deploy** behave as AUDIT.md states; the stage 2 script cannot run before the reveal or from a wallet that does not own the NFT.\n\nTwo info-level observations are in `.imd-findings.json`, both pre-launch and owner-only:\n\n1. `CourierNFT.setTreasury` updates mint revenue but leaves the ERC-2981 royalty receiver on the old treasury, unlike the constructor which sets both.\n2. `CourierNFT.renounceOwnership` does not require `freezeRenderer`, so the `rendererFrozen` flag can stay false on an ownerless collection even though the art is in fact final. The mainnet script freezes first, so the shipped flow is correct.\n\nItems I considered and did not report: a 1-wei exact-in buy through a generic router pays its whole 1 wei as fee, which is within the stated \"1 wei over 4%\" guarantee and rejected by the project's own router; stage 2 is twelve separate transactions rather than one, but every partial-failure state I traced remains recoverable by the deployer because the hook is renounced last.\n\nCoverage limits: no Slither or symbolic tooling was available, and I did not test against a non-standard IMD implementation beyond the live token on the fork.","treeHash":null,"usage":{"cachedInputTokens":3280748,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":72528,"runtime":"claude","turns":50,"wallClockMs":1321636}}],"verification":[]}