{"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":"25e6a0b3-8bc8-422f-820c-d984e1517f57","kind":"audit","nodes":[{"acceptedSubmissionHash":"4d723cef5620349a46ef456d822b81adb0f1f001376a18578b7eaa3b58ff94b1","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":"ffa0e2ed42ce92e5ed21483e71c77b6f7673554916620be83e7b514a430e1c67","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":"4d9466c86b78225782e8e4dfc731fdf91054506692c86e309b13458def1b50f3","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":"b9eb1bc00e78425f86ba6c85c14094bbb9cbd6a5ed0af4535e2ec4db941b81c6","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":"a34a06af69ae832c9e2616f2a2afe3ebcd44bc45a190c6336fde3d507acd6c11","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), new contracts since launch: Courier Skins (CourierSkins.sol) and the golden stamp hunt (StampHunt.sol). The code is on branch audit-skins-hunt, commit 2cf3432 (https://github.com/AdamNakamoto/courierworld/tree/audit-skins-hunt); main does not have it yet. Read AUDIT.md section 9 first: it describes both contracts, what they must guarantee and what is accepted. Sections 1-8 cover the live game, audited in ea514609, ca28d248 and 19b34b9b; it is deployed, unchanged, and the new contracts only read from it.\n\nWhat it is: a game on Robinhood Chain (4663). CourierSkins is 999 cosmetic NFTs (16 designs) players wear in the game. Blind mint for ETH (0.004, up to 10 per transaction), paid straight to the treasury. A commit-reveal seed (secret mixed with blockhash at the reveal) keys a 4-round Feistel shuffle on 10 bits with cycle-walking, so each token gets a distinct card of a 999-card deck and a full mint matches every design's supply. Metadata is on-chain JSON (tokenURI and ERC-7572 contractURI); pictures come from an owner-set image base that can be frozen. The 5% royalty is enforced with ERC721-C: every transfer between two non-zero addresses calls OpenSea's StrictAuthorizedTransferSecurityRegistry (0xA000027A9B2802E1ddf7000061001e5c005A0000, live on Robinhood Chain). The rate is fixed; the validator can be replaced but never removed. StampHunt pays $STAMP from a pool the owner fills (it never mints) for golden stamps found in the game: the game server signs an EIP-712 voucher Finds(player, day, stamps, deadline), only wallets with an open post office can claim, and payouts are capped per wallet per day, per day in total, and by the pool.\n\nScope: contracts/src/CourierSkins.sol, contracts/src/StampHunt.sol, contracts/script/DeploySkins.s.sol, contracts/script/DeployHunt.s.sol. Out of scope: contracts/script/DevTransferValidator.sol (local chains only), web/ and multiplayer/.\n\nWhat to check:\n\nSkins never exceed 999, every mint pays exactly price x quantity, and the payment reaches the treasury in the same call; nothing is ever left in the contract.\nNobody, owner included, can know or influence any design before the reveal; designs never change after it; _shuffle is a bijection on 0..998 for every seed, so no design ever exceeds its supply.\nNo path moves a skin between two non-zero addresses without calling the validator (transferFrom, both safeTransferFroms, anything reaching _update); the validator can't be set to zero or a non-contract; the royalty rate can't change from 5%; a holder can always move their own skin under the launch registry.\ntokenURI and contractURI always return valid base64 JSON, and designOf agrees with tokenURI.\nStampHunt pays only vouchers signed by signer for msg.sender, never pays a voucher beyond its count, never pays a wallet for more than maxStampsPerDay stamps in a day, never exceeds dailyLimit or the pool, pays only wallets with an open post office, and can't mint or touch any tokens but its own pool.\n\nTests: cd contracts; git submodule update --init --recursive; forge test --match-contract \"CourierSkinsTest|StampHuntTest\" (28 tests). Fork: forge test --match-contract CourierSkinsForkTest --fork-url https://robinhood.drpc.org (against OpenSea's real registry: holder transfers pass, a marketplace without the fee check is refused). All 125 tests: forge test.","parentJobId":null,"planHash":"502510c36d2077e17d5593d159ce4bddc38839b1d5c20fadb956b1d00b85b9c7","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"25e6a0b3-8bc8-422f-820c-d984e1517f57","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":"52164","feedbackHash":"c50eaa5b97061464eb4636e52775a624cbbbf64a66831c70145b0ab68ee200de","nodeKey":"audit_economics","submissionHash":"4d723cef5620349a46ef456d822b81adb0f1f001376a18578b7eaa3b58ff94b1","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52129","feedbackHash":"1aaa01b24dc0458143fbe6afa84faf512acaba5539f8b3970ce2d2b71d0c5cbb","nodeKey":"audit_flow","submissionHash":"ffa0e2ed42ce92e5ed21483e71c77b6f7673554916620be83e7b514a430e1c67","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51169","feedbackHash":"1b9b51fb99c24a1b7b152b4250634658a8c56bad59d1d73b908e29e4c4c3765c","nodeKey":"audit_judge","submissionHash":"4d9466c86b78225782e8e4dfc731fdf91054506692c86e309b13458def1b50f3","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52286","feedbackHash":"9e616cc150ccb673188c64c383f050285c6dbb84da32ef9c1d04aeaeb8af4e04","nodeKey":"audit_math","submissionHash":"b9eb1bc00e78425f86ba6c85c14094bbb9cbd6a5ed0af4535e2ec4db941b81c6","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52266","feedbackHash":"93b96e9aad42659165fdf3be059a9d93e63322f3682052f41bba03f59a5061c9","nodeKey":"audit_permissions","submissionHash":"a34a06af69ae832c9e2616f2a2afe3ebcd44bc45a190c6336fde3d507acd6c11","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"21e3b47ec7ef1895e6fac1eac93084c1721e2c7e59a0f0b1ee4f6126a2a22dcf","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"63458a1bd2b2d376","findings":[{"citation":"resolved","description":"The brief's 'fair reveal' guarantee says nobody can know or influence a design before reveal. reveal(secret) is the transaction that both discloses the secret (in calldata) and closes the sale, in that order inside one call. Anyone who sees the pending transaction before it is sequenced (the RPC provider the owner submits through, the sequencer operator, or anyone else with access to the pending feed) knows the secret, and on an Arbitrum Orbit chain blockhash(block.number - 1) is a deterministic pseudo-random value that is identical for every L2 block sharing the same L1 block number, so the seed the reveal will produce is computable in advance. The observer can then run the public _shuffle/_designAt logic over the ids about to be minted (totalMinted+1 onwards) and buy exactly enough skins, 10 per transaction, to land on a 13-supply Moonwedge before the reveal is included. Design choice is thereby influenced by a minter, which the brief rules out. Robinhood Chain has no public mempool, so the set of possible actors is small, hence low severity, but the owner's own RPC endpoint is in that set. Fix: close the sale in its own transaction and require it closed before revealing (e.g. `if (saleOpen) revert SaleOpen();` or `SaleStillOpen()` in reveal), or at minimum document the two-step runbook (setSaleOpen(false), wait one block, reveal). Keeping the 'reveal ends the sale' convenience is incompatible with the guarantee as stated.","line":122,"path":"contracts/src/CourierSkins.sol","reproduction":"State: sale open, 10 skins minted. Owner broadcasts reveal(424242) at block N. Attacker reads the secret from the pending tx, computes s = keccak256(abi.encode(424242, blockhash(N-1))) (1 if zero), replicates _shuffle/_designAt for ids 11..999, finds the first id whose design is >= 11 (a Legendary), mints that many skins in block N ahead of the reveal, then the reveal lands. Expected (brief): no minter can know or influence a design before reveal. Actual: skins.seed() == s exactly, and designOf(chosen id) >= 11 is owned by the attacker. Verified in a Foundry test (replica of _shuffle in the test, mint in the same block, then reveal; asserts seed equality and designOf >= 11).","severity":"low","snippet":"        if (saleOpen) {\n            saleOpen = false;\n            emit SaleOpen(false);\n        }\n        uint256 s = uint256(keccak256(abi.encode(secret, blockhash(block.number - 1))));","title":"reveal() publishes the secret while the sale is still open, so a front-runner can pick designs before the seed lands"},{"citation":"resolved","description":"Section 9 states 'The owner can't mint for free'. setPrice has no floor, so the owner can set the price to 0, mint up to 999 skins at no cost, and restore the price. While the price is 0 anyone else can also mint for free, so a buyer who paid 0.004 ETH cannot rely on every other skin having been paid for. This is an owner power, not a bypass, and is reported as a documentation/trust mismatch: either document that the owner can zero the price (and therefore mint free), or add a minimum price (e.g. revert if price_ == 0) if the brief's wording is meant literally.","line":330,"path":"contracts/src/CourierSkins.sol","reproduction":"Owner calls setPrice(0); owner calls mint{value:0}(10); bob calls mint{value:0}(10). Expected (per AUDIT.md section 9): the owner cannot mint for free. Actual: both calls succeed, balanceOf(owner)==10, balanceOf(bob)==10, treasury.balance==0. Verified in a Foundry test.","severity":"info","snippet":"        price = price_;","title":"AUDIT.md says the owner can't mint for free, but setPrice(0) makes every mint free"},{"citation":"resolved","description":"Section 9 says setTransferValidator 'only takes a contract, so enforcement can move but never be switched off', and the contract NatSpec repeats 'never removed'. The only check is code.length != 0. A contract whose validateTransfer is a no-op (or any contract with a non-reverting fallback) passes it, after which every approved operator, including a marketplace that pays no royalty, can move skins. The 'known and accepted' paragraph admits the validator is trusted, so this is reported as a wording/trust mismatch rather than a bypass: buyers and marketplaces reading the brief would believe the 5% is irrevocable, while in fact it is irrevocable only after renounceOwnership. Suggested fix: keep the owner power but state plainly that until renounceOwnership the owner can disable enforcement, or restrict the setter to an allow-list of known registries.","line":307,"path":"contracts/src/CourierSkins.sol","reproduction":"alice mints skin 1 and approves `market`. market.transferFrom(alice,bob,1) reverts under the strict validator. Owner calls setTransferValidator(address of a contract whose validateTransfer(address,address,address,uint256) is `external pure {}`): accepted because code.length != 0. market.transferFrom(alice,bob,1) now succeeds, ownerOf(1)==bob, no royalty involved. Expected per the brief: enforcement cannot be switched off. Verified in a Foundry test.","severity":"info","snippet":"        if (validator.code.length == 0) revert NotAContract();","title":"'Enforcement can never be switched off' only means the validator is a contract; any permissive contract turns royalty enforcement off"},{"citation":"resolved","description":"claimable() reduces the payable count by both the pool balance and the remaining dailyLimit. When the count reaches 0 because the day's limit is used up, claim() falls through to revert PoolEmpty() although the pool may hold thousands of $STAMP. The game client and anyone reading the revert get the wrong reason (the fix for an empty pool is a deposit; the fix for the daily limit is to wait a day), and a player who sees 'pool empty' may assume the hunt has been drained. Fix: check `dailyLimit != 0 && paidFor[day] + rewardPerStamp > dailyLimit` first and revert a dedicated DailyLimitReached() error.","line":132,"path":"contracts/src/StampHunt.sol","reproduction":"StampHunt with reward 10e18, maxStampsPerDay 24, dailyLimit 40e18, pool 1000e18, alice and bob with open offices. alice claims a voucher for 4 stamps: paid 40e18, paidFor[day]==40e18, pool 960e18. bob then claims a valid voucher for 4 stamps. Expected: a revert naming the daily limit. Actual: revert PoolEmpty() while pool()==960e18. Verified in a Foundry test.","severity":"info","snippet":"        revert PoolEmpty();","title":"claim() reports PoolEmpty when the binding constraint is the daily limit, not the pool"},{"citation":"resolved","description":"The per-wallet daily cap is read as uint256 from the environment and narrowed with an unchecked explicit cast. A value above 65535 wraps instead of failing, so a mis-typed setting deploys a hunt with a different cap than the one logged in the operator's shell, and the script prints no warning. Fix: read with vm.envOr into a uint256, `require(v <= type(uint16).max)`, then cast; or log the value the constructor receives.","line":29,"path":"contracts/script/DeployHunt.s.sol","reproduction":"HUNT_MAX_PER_DAY=70000 ./script ... DeployHunt: uint16(70000) == 4464, so the hunt deploys with maxStampsPerDay == 4464 and no error. HUNT_MAX_PER_DAY=65560 yields 24, indistinguishable from the default. Expected: the script refuses an out-of-range value.","severity":"info","snippet":"            uint16(vm.envOr(\"HUNT_MAX_PER_DAY\", uint256(24))),","title":"DeployHunt silently truncates HUNT_MAX_PER_DAY to 16 bits"}],"hash":"4d723cef5620349a46ef456d822b81adb0f1f001376a18578b7eaa3b58ff94b1","nodeId":"3addf9bc-ad41-4bd8-92f6-dc5ec4960438","outcome":"completed","summary":"The review is complete and `.imd-findings.json` holds five findings, all verified with Foundry tests that I ran and then removed from `test/scratch/`.\n\n**Outcome: no critical, high or medium defects in the assigned scope.** Both new contracts uphold the section 9 guarantees I was asked to check:\n\n- **Skins supply and payment.** Mint caps at 999, takes exactly price × quantity, forwards it to the treasury in the same call, and the contract has no receive or fallback. Reentrancy is not reachable because `_mint` has no callback and the only external call goes to the trusted treasury.\n- **Fair reveal and shuffle.** The 4-round Feistel is invertible by construction, so cycle-walking gives a permutation of 0..998 for every seed. I fuzzed a replica across 300 seeds with the walk never exceeding 26 steps. Designs are fixed once the seed is set, and the sale can never reopen afterwards.\n- **Royalty enforcement.** Every path between two non-zero addresses goes through the overridden `_update`, which calls the validator as a staticcall before any state change. The rate is a constant with no setter. The fork test against OpenSea's real registry on Robinhood Chain passed: holder transfers go through and an approved operator without the fee check is refused.\n- **Metadata.** `designOf` and `tokenURI` compute the same expression. JSON validity depends only on `imageBase`, which the brief already accepts as trusted.\n- **Hunt payouts.** The voucher binds the caller, day, count and deadline under an EIP-712 domain with chain id and contract address. Per-wallet, per-day and pool caps all hold, re-submitting a voucher pays nothing beyond its count, and the contract only ever moves its own $STAMP.\n\n**What I reported** (one low, four info):\n\n1. **Low:** `reveal` discloses the secret in calldata while the sale is still open in the same transaction. On an Orbit chain the previous blockhash is deterministic for the current L1 block, so anyone who sees the pending transaction can compute the seed and mint ids that land on a legendary before it is included. Robinhood Chain has no public mempool, so only the RPC provider or sequencer operator could do this. Fix: require the sale closed before revealing, or run the close in an earlier block.\n2. **Info:** `setPrice(0)` lets the owner, and anyone else in that window, mint for free, contradicting the brief's \"the owner can't mint for free\".\n3. **Info:** \"enforcement can never be switched off\" only means the validator has code. A no-op validator contract disables the royalty check entirely until ownership is renounced.\n4. **Info:** `claim` reverts `PoolEmpty` when the day's limit is the real constraint, with 960 $STAMP still in the pool in my reproduction.\n5. **Info:** the deploy script narrows `HUNT_MAX_PER_DAY` with an unchecked cast, so 65560 silently becomes 24.\n\n**Test-coverage gaps worth closing** (not defects): the fuzz test for different seeds only asserts the design index is below 16, which any slot satisfies, so it does not exercise the bijection claim. There is no test that the four-argument `safeTransferFrom` reaches the validator, none for the daily-limit revert path, and none for a voucher claimed after the UTC day boundary.","treeHash":null,"usage":{"cachedInputTokens":1474926,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":38723,"runtime":"claude","turns":30,"wallClockMs":585353}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"87804e27e9c9f85a","findings":[{"citation":"resolved","description":"Guarantee 2 (AUDIT.md section 9) and the NatSpec on reveal ('the owner can't pre-compute the designs either') say nobody, minter or owner, can know or influence a design before the reveal. The seed is keccak256(secret, blockhash(block.number - 1)). Both inputs are known before the reveal transaction executes: the secret is the owner's, and blockhash(block.number - 1) is already final when a transaction for the current block is built. Nothing forces minting to have stopped before that hash is fixed: reveal() closes the sale itself (line 122), and setSaleOpen(false) can be undone by setSaleOpen(true) until the reveal. So while the sale is open the owner can compute the exact seed a reveal would set now, run the public _shuffle/_designAt logic for IDs totalMinted+1 onward, mint exactly the IDs that land on Golden/Hologram Couriers or Moonwedges (10 per call, repeatable), and then reveal under the same hash; since mint payments go to the owner-set treasury the mints cost it only gas. On Robinhood Chain (Arbitrum Nitro, chain 4663) this needs no same-block bundle: block.number is the parent-chain (L1) block number and blockhash(block.number - 1) is ArbOS's deterministic value for that L1 block, identical for every L2 block of one L1 block. Observed live during this review: L2 blocks 86019789 and 86019829, four seconds apart, both report l1BlockNumber 26170803. Two ordinary transactions a few seconds apart therefore share the reveal entropy. A second, weaker actor exists: anyone who sees the reveal calldata before it is sequenced (the owner's RPC provider, the sequencer operator) learns the secret while the sale is still open and can mint the predicted IDs ahead of the reveal; Robinhood Chain has no public mempool, so that set is small. This is stronger than the 'Reveal timing' item in Known and accepted (the owner may wait for a blockhash it likes, which re-rolls the whole mapping): here the owner chooses which unminted deck slots it takes with certainty, so unminted legendaries are never really at stake for public minters once the sale slows. Minimal fix that keeps the design: refuse a reveal that can share its blockhash with an open sale. For example record saleClosedAt = block.number in setSaleOpen(false) (reset on reopen) and in reveal require !saleOpen && block.number > saleClosedAt, so the hash mixed in did not exist while anyone could still mint; document that on Arbitrum-family chains 'a later block' means a later L1 block. Alternatively make reveal() two-step: close and record the block, then draw the seed from blockhash(recorded + 1) once it exists (within 256 blocks). Merged from four specialist reports (audit_math, audit_flow, audit_economics, audit_permissions) describing the same mechanism.","line":126,"path":"contracts/src/CourierSkins.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {CourierSkins} from \"src/CourierSkins.sol\";\n\ncontract PassValidator {\n    function validateTransfer(address, address, address, uint256) external view {}\n}\n\n/// Guarantee 2 of AUDIT.md section 9: before `reveal` nobody, owner included, can know or influence any\n/// token's design. The seed is keccak256(secret, blockhash(block.number - 1)), and both inputs are known to\n/// the owner while the sale is still open (the sale is only closed inside `reveal` itself). So the owner can\n/// compute the seed, predict the designs of the next unminted IDs, mint exactly those, and reveal in the same\n/// block. This test fails on the current code (the prediction matches every minted token) and passes once\n/// minting is impossible with the reveal entropy in hand (e.g. `reveal` requires the sale to have been closed\n/// in an earlier block, or the seed is drawn from a block after the sale closed).\ncontract OwnerPredictsDesignsTest is Test {\n    CourierSkins skins;\n    address owner = makeAddr(\"owner\");\n    address treasury = makeAddr(\"treasury\");\n    address alice = makeAddr(\"alice\");\n    uint256 constant PRICE = 0.004 ether;\n    uint256 constant SECRET = 0x5EC12E7;\n\n    function setUp() public {\n        skins = new CourierSkins(owner, treasury, PRICE, keccak256(abi.encode(SECRET)), address(new PassValidator()), \"x/\");\n        vm.prank(owner);\n        skins.setSaleOpen(true);\n        vm.deal(alice, 1 ether);\n        vm.deal(owner, 1 ether);\n        vm.prank(alice);\n        skins.mint{value: PRICE * 5}(5); // the public sale: tokens 1..5\n        vm.roll(block.number + 10);\n    }\n\n    // The owner's off-chain replica of the contract's shuffle, keyed by the seed it can already compute.\n    function _shuffle(uint256 s, uint256 x) internal pure returns (uint256) {\n        do {\n            uint256 l = x >> 5;\n            uint256 r = x & 31;\n            for (uint256 i = 0; i < 4; i++) {\n                (l, r) = (r, l ^ (uint256(keccak256(abi.encode(s, i, r))) & 31));\n            }\n            x = (l << 5) | r;\n        } while (x >= 999);\n        return x;\n    }\n\n    function _designAt(uint256 slot) internal view returns (uint256 design) {\n        uint256 end;\n        for (design = 0; design < 16; design++) {\n            end += skins.supplyOf(design);\n            if (slot < end) return design;\n        }\n        revert();\n    }\n\n    function test_OwnerCannotMintKnowingTheDesigns() public {\n        // What the owner knows before anyone else: the secret, and this block's reveal entropy.\n        uint256 s = uint256(keccak256(abi.encode(SECRET, blockhash(block.number - 1))));\n        if (s == 0) s = 1;\n        uint256 first = skins.totalMinted() + 1; // 6\n        uint256[10] memory predicted;\n        for (uint256 i = 0; i < 10; i++) predicted[i] = _designAt(_shuffle(s, first + i - 1));\n\n        // The owner mints the next ten (paying its own treasury) and reveals in the same block.\n        vm.startPrank(owner);\n        (bool minted,) = address(skins).call{value: PRICE * 10}(abi.encodeCall(CourierSkins.mint, (10)));\n        (bool revealed,) = address(skins).call(abi.encodeCall(CourierSkins.reveal, (SECRET)));\n        vm.stopPrank();\n\n        bool allMatch = minted && revealed && skins.seed() != 0;\n        for (uint256 i = 0; allMatch && i < 10; i++) {\n            if (skins.designOf(first + i) != predicted[i]) allMatch = false;\n        }\n        assertFalse(allMatch, \"owner minted tokens whose designs it had already computed, then revealed\");\n    }\n}","reproduction":"State: sale open, tokens 1..5 minted by a player, secret S known to the owner, commit = keccak256(abi.encode(S)), block rolled forward 10 blocks. The owner computes s = keccak256(abi.encode(S, blockhash(block.number - 1))) (s == 0 -> 1) and predicted[i] = _designAt(_shuffle_s(5 + i)) for i = 0..9 (the designs tokens 6..15 will get), then in the same block calls mint{value: 0.04 ether}(10) and reveal(S). Expected (guarantee 2): the reveal is refused or designOf(6..15) does not match the prediction. Actual: both calls succeed, skins.seed() == s, and designOf(6 + i) == predicted[i] for all ten. Run: forge test --match-path test/scratch/Proof_d6a1e26c635c.t.sol fails with 'owner minted tokens whose designs it had already computed, then revealed'. The second specialist proof (Proof_9e47ecaacd12.t.sol, treasury == owner, 100 public mints, IDs 101..110) fails the same way. Both pass once reveal() reverts while the sale is open or in the block the sale was closed.","severity":"medium","snippet":"        uint256 s = uint256(keccak256(abi.encode(secret, blockhash(block.number - 1))));","title":"reveal() may run while the sale is open, so the seed is computable before it lands and the owner (or anyone who sees the secret in the pending reveal) can mint IDs whose designs it already knows"},{"citation":"resolved","description":"The per-wallet daily cap is read as uint256 from the environment and narrowed with a plain uint16() cast, which truncates instead of reverting: 70000 becomes 4464, 65560 becomes 24 (indistinguishable from the default), and any multiple of 65536 becomes 0. Neither the StampHunt constructor (contracts/src/StampHunt.sol:87) nor setLimits (line 171) rejects maxStampsPerDay == 0, and unlike dailyLimit, 0 does not mean 'no limit' there: claimable() computes cap = 0 and returns (0, 0), so every claim() reverts NothingToClaim however full the pool is. The two limits set in the same call thus have opposite zero semantics. The script also passes STAMP and OFFICE straight into immutables with no check that OFFICE has code and answers offices(address) with the nine-field tuple, or that it belongs to the given STAMP (deployments/robinhood.json holds the live pair but the script reads raw env), so a swapped pair deploys cleanly and every claim and deposit then reverts; the only remedy for either mistake in the immutables is to redeploy and repoint the signer and front end. Fix: in the script read the cap as uint256 and require 0 < v <= type(uint16).max before casting, require OFFICE.code.length > 0 (ideally PostOffice(OFFICE).stamp() == STAMP), and log the values the constructor receives; in StampHunt revert on maxStampsPerDay_ == 0 in the constructor and setLimits, or define 0 as 'no cap' consistently with dailyLimit. Merged from audit_math, audit_economics, audit_permissions and audit_flow reports on the same line.","line":29,"path":"contracts/script/DeployHunt.s.sol","reproduction":"Scratch test test/scratch/Probe.t.sol::test_DeployHuntTruncatesMaxPerDay: with STAMP, OFFICE, HUNT_SIGNER and HUNT_OWNER set, HUNT_MAX_PER_DAY=70000 -> new DeployHunt().run() returns a hunt with maxStampsPerDay() == 4464; HUNT_MAX_PER_DAY=65536 -> 0; HUNT_MAX_PER_DAY=65560 -> 24. Expected: the script refuses an out-of-range value. test_MaxPerDayZeroStopsEveryPayout: StampHunt deployed with maxStampsPerDay 0 and a 1000e18 pool; claimable(alice, today, 3) returns (0, 0) and a validly signed claim(today, 3, deadline, sig) from a wallet with an open office reverts NothingToClaim; the same state is reached on a live hunt by setLimits(0, 10_000e18). test_DeployHuntSwappedAddressesDeploys: STAMP and OFFICE swapped -> deployment succeeds with stamp() == the office address and the first claim reverts. All three pass on the current code.","severity":"low","snippet":"            uint16(vm.envOr(\"HUNT_MAX_PER_DAY\", uint256(24))),","title":"DeployHunt narrows HUNT_MAX_PER_DAY to uint16 unchecked and validates none of its inputs; a cap of 0 is accepted by StampHunt and silently stops every payout"},{"citation":"resolved","description":"Before the reveal tokenURI returns 'Courier Skin #n', status Sealed and mystery.png; after it, the design, rarity, type and picture. The reveal therefore changes the metadata of every minted token at once, and setImageBase changes every token's image. OpenSea and other marketplaces that cache tokenURI re-read it automatically only when the collection emits ERC-4906's MetadataUpdate(uint256) or BatchMetadataUpdate(uint256,uint256) and reports interface 0x49064906 in supportsInterface. CourierSkins emits only SaleOpen(false) and Revealed(seed) in reveal and ImageBaseSet plus ContractURIUpdated in setImageBase, and supportsInterface(0x49064906) is false. Listed skins keep the sealed name, mystery picture and no Design/Rarity traits until each holder refreshes manually, which is exactly when the traits drive pricing and the enforced royalty's value. Fix: emit BatchMetadataUpdate(1, totalMinted) in reveal (and in setImageBase when totalMinted > 0) and return true for 0x49064906 in supportsInterface. Reported by audit_flow; kept after reproduction.","line":128,"path":"contracts/src/CourierSkins.sol","reproduction":"Scratch test test/scratch/Probe.t.sol::test_NoMetadataUpdateEventOnReveal: mint 3 tokens, roll 5 blocks, vm.recordLogs(), reveal(secret), setImageBase('y/'). Expected: a BatchMetadataUpdate(1,3) or MetadataUpdate log and supportsInterface(0x49064906) == true. Actual: 4 logs, none with topic0 keccak256('MetadataUpdate(uint256)') or keccak256('BatchMetadataUpdate(uint256,uint256)'), tokenURI(1) differs from before the reveal, and supportsInterface(0x49064906) returns false. Passes on the current code.","severity":"low","snippet":"        emit Revealed(seed);","title":"reveal() and setImageBase() rewrite every token's metadata but emit no ERC-4906 event, so marketplaces keep showing skins as sealed"},{"citation":"resolved","description":"claimable() returns count 0 for three reasons: the wallet already reached its per-day cap, the pool cannot cover one stamp, or dailyLimit - paidFor[day] cannot. claim() distinguishes only the first (NothingToClaim) and reports the other two as PoolEmpty, so a claim blocked by the day's global limit says the pool is empty while it may hold thousands of $STAMP. The accounting is correct (nothing is overpaid and the stamps stay claimable the next day or after setLimits), but the game client, support, or the owner reading the revert will act on the wrong cause: a deposit does not unblock the claim. Fix: add a DailyLimitReached error and choose it when dailyLimit != 0 && dailyLimit - paidFor[day] < rewardPerStamp before falling back to PoolEmpty. Merged from identical reports by all four specialists.","line":132,"path":"contracts/src/StampHunt.sol","reproduction":"Scratch test test/scratch/Probe.t.sol::test_DailyLimitRevertsPoolEmpty: StampHunt with reward 10e18, maxStampsPerDay 24, dailyLimit 50e18, pool 1000e18, alice and bob with open offices. alice claims a valid 5-stamp voucher for today: paid 50e18, paidFor[today] == 50e18, pool 950e18. bob then calls claim with a valid 4-stamp voucher for today. Expected: a revert naming the daily limit. Actual: claimable(bob, today, 4) == (0, 0) and the claim reverts PoolEmpty() while pool() == 950e18. Passes on the current code.","severity":"info","snippet":"            revert PoolEmpty();","title":"claim() reverts PoolEmpty when it is the day's dailyLimit, not the pool, that is exhausted"},{"citation":"resolved","description":"Section 9 lists 'mint for free' among the things the owner can't do. setPrice has no floor and no timing restriction, and mint only checks msg.value == price * quantity, so the owner can set the price to 0, mint 10 per call as often as it likes, and restore the price, with PriceSet events as the only trace; while the price is 0 any other wallet can mint free too, so a buyer who paid 0.004 ETH cannot rely on every other skin having been paid for. Independently, every mint payment goes to the owner-controlled treasury, so the owner's mints cost it nothing even at full price. This is an owner power, not a bypass of any check, and the sale is blind, so it is reported as a trust-model statement that is too strong; it is also what makes the owner's knowledge-based mint in the medium finding free. Fix: either document the power as accepted, or add a minimum price (revert on price_ == 0) or a one-way price lock the owner sets before opening the sale. Merged from audit_economics, audit_permissions and audit_flow.","line":330,"path":"contracts/src/CourierSkins.sol","reproduction":"Scratch test test/scratch/Probe.t.sol::test_OwnerMintsFreeViaPriceZero: owner calls setPrice(0), mint{value: 0}(10); bob calls mint{value: 0}(10); owner calls setPrice(0.004 ether). Expected per AUDIT.md: the owner cannot mint for free. Actual: balanceOf(owner) == 10, balanceOf(bob) == 10, treasury.balance == 0, price back at 0.004 ether. Passes on the current code.","severity":"info","snippet":"        price = price_;","title":"AUDIT.md says the owner can't mint for free, but setPrice(0) makes every mint free and owner mints at any price only pay the owner's own treasury"},{"citation":"resolved","description":"Guarantee 1 says nothing is ever left in the contract. Mint payments are forwarded in full and there is no receive or fallback, so ordinary payments never accumulate. ETH can still be placed in the contract without calling it: SELFDESTRUCT of a contract created in the same transaction still transfers its balance after EIP-6780 (Cancun, the configured evm_version), and the deterministic address can be pre-funded before deployment. Such ETH is stuck forever; only the sender loses, so this is informational. A one-line change keeps the guarantee exactly: forward address(this).balance instead of msg.value in mint, or add an owner-only sweep to the treasury. Merged from audit_permissions and audit_flow.","line":112,"path":"contracts/src/CourierSkins.sol","reproduction":"Scratch test test/scratch/Probe.t.sol::test_ForcedEthIsStuck: deploy `contract Burner { constructor(address payable to) payable { selfdestruct(to); } }` with 1 ether and to = address(skins). Then alice mints 1 skin at 0.004 ether. Expected under guarantee 1: address(skins).balance == 0 afterwards. Actual: the treasury receives exactly 0.004 ether, address(skins).balance stays 1 ether, and a plain call with value to the contract reverts. Passes on the current code.","severity":"info","snippet":"        Address.sendValue(payable(treasury), msg.value);","title":"ETH forced into CourierSkins can never leave it: mint forwards msg.value, not the balance, and there is no receive, fallback or sweep"},{"citation":"resolved","description":"Every reveal in the suite goes through _reveal(), which rolls five blocks first, so no test mints and reveals under one blockhash(block.number - 1), computes the seed off-chain before the reveal, or asserts anything about who can know a design before it; that is the edge the medium finding lives on. Bijectivity of _shuffle is asserted for a single seed (test_FullMintMatchesEverySupply); testFuzz_DifferentSeedsGiveAValidDeal only checks designOf < 16. The marketplace-authorized path is only exercised against the mock StrictValidator; the fork tests show a holder transfer passing and an unauthorized operator refused under OpenSea's live registry, but none shows an authorized sale through the registry's beforeAuthorizedTransfer flow succeeding, so the only evidence that marketplace sales work at all is the mock. Also untested: maxStampsPerDay == 0 and setLimits lowering the cap below a wallet's claimed count; dailyLimit or the pool not being a multiple of rewardPerStamp; a claim exactly at deadline and exactly at the UTC day rollover; safeTransferFrom with data through the validator; mint with price == 0. The suite passes (28 tests, and 125 in all), so none of this is a defect in the contracts; it is where the stated guarantees are assumed rather than checked. Merged from audit_math and audit_flow.","line":55,"path":"contracts/test/CourierSkins.t.sol","reproduction":"grep -n 'vm.roll' contracts/test/CourierSkins.t.sol shows the only roll is in _reveal() at line 55, and every reveal call site uses it; no test calls skins.reveal in the block of a mint. forge test --match-contract CourierSkinsTest -vvvv shows block.number at each reveal is setUp's number + 5 with no mint in that block. The scratch tests test/scratch/Proof_d6a1e26c635c.t.sol (mint and reveal in one block) and Probe.t.sol::test_BijectionSeveralSeeds (full deal checked for three seeds: every design count equals supplyOf) are the missing cases; the latter passes.","severity":"info","snippet":"        vm.roll(block.number + 5);","title":"Test gaps: no test reveals under the same blockhash as a mint, and several hunt and mint edges are unexercised"}],"hash":"4d9466c86b78225782e8e4dfc731fdf91054506692c86e309b13458def1b50f3","nodeId":"5e71d97b-6dca-4699-9e8d-f7e5d47f6caa","outcome":"completed","summary":"The review is complete. The findings file `.imd-findings.json` holds 7 merged findings, every one reproduced against commit 2cf3432, and the contract sources are untouched.\n\n**What I kept, after reproducing each claim**\n\n- **Medium, CourierSkins reveal.** The seed is computable before the reveal lands and the sale is still open at that moment, so the owner (or anyone who sees the secret in the pending reveal) can mint the exact IDs it knows are legendary, then reveal under the same hash. Four specialists reported this; I merged them into one finding. Both attached specialist proofs fail on the current code with the stated message and pass once `reveal` refuses an open sale, which I verified by patching and restoring the file. I also confirmed the Arbitrum semantic the attack relies on: two live Robinhood Chain blocks four seconds apart shared the same L1 block number. The NatSpec claim that the owner can't pre-compute designs is false as written.\n- **Low, DeployHunt.** The per-wallet cap is truncated to 16 bits unchecked, a cap of 0 is accepted and stops every payout with `NothingToClaim`, and swapped STAMP/OFFICE addresses deploy cleanly. Merged from four reports. Reproduced by running the script inside a test: 70000 became 4464, 65536 became 0, 65560 became 24.\n- **Low, no ERC-4906 event** on reveal or image-base change, so marketplaces keep the sealed metadata. Reproduced with recorded logs.\n- **Info, three items.** `claim` says `PoolEmpty` when the daily limit is the binding constraint. The brief's \"owner can't mint for free\" is not enforced because `setPrice(0)` works and the treasury is owner-chosen. Force-sent ETH is stuck because `mint` forwards `msg.value`, not the balance. Each reproduced in my probe suite.\n- **Info, test gaps**, merged from two reports: no test reveals in a mint's block, bijectivity checked for one seed, and no test of an authorized marketplace sale against the live registry.\n\n**What I dropped**\n\nThe claim that a permissive validator contract switches off royalty enforcement is already covered by the accepted \"the validator is trusted\" item, and the code meets guarantee 3 as stated, so it is not a defect.\n\n**Verification run**\n\n| Check | Result |\n| --- | --- |\n| Two in-scope suites | 28 passed |\n| Fork tests against OpenSea's live registry | 2 passed |\n| Full project suite | 125 passed |\n| My probe suite in test/scratch | 8 passed, including shuffle bijection for 3 seeds |\n| Specialist proofs on current code | both fail as stated; both pass on a fixed `reveal` |\n\nBeyond the specialists' leads I found nothing new: the Feistel shuffle is a bijection, the deck sums to 999, `_update` guards every wallet-to-wallet path, the hunt's voucher and cap arithmetic cannot overpay, and the hunt's `offices` interface matches the live PostOffice struct.","treeHash":null,"usage":{"cachedInputTokens":789607,"inputTokens":290,"model":"claude-fable-5-1","outputTokens":28851,"runtime":"claude","turns":24,"wallClockMs":594872}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"644eb561f6d70a85","findings":[{"citation":"resolved","description":"Guarantee 2 (AUDIT.md section 9) says nobody, minter or owner, can know or influence a token's design before the reveal. The seed is keccak256(secret, blockhash(block.number - 1)). Inside the block that will contain reveal(), blockhash(block.number - 1) is already fixed, so anyone who knows `secret` at that moment can compute the final seed, run the public _shuffle/_designAt logic for token IDs totalMinted+1 onward, and mint exactly the IDs that land on legendaries before reveal() executes and closes the sale. Two actors can do this: (a) the owner, who knows the secret and can mint and reveal atomically in one transaction (the mint price goes to the owner-chosen treasury, so it costs only gas); (b) anyone who sees the reveal transaction before it is sequenced (the secret is plaintext calldata), e.g. the sequencer operator or a builder, since the sale stays open until reveal() runs. On Robinhood Chain (Arbitrum-style) block.number is the parent-chain block number, so blockhash(block.number - 1) is constant across every L2 block in the same parent block, which widens the window from one block to about 12 seconds. The accepted note covers the owner re-rolling the global mapping by choosing when to reveal; this is a stronger capability: targeted acquisition of specific rare designs with certainty. No existing test mints in the reveal block. A minimal fix that keeps the design: have reveal() revert while saleOpen is true, and record the block in which the sale was last closed in setSaleOpen(false) and require block.number > that block in reveal(), so no mint can precede the reveal with a known seed (reopening the sale remains an owner power, which is already a trust assumption). Alternatively bind the seed to a block chosen after the sale closes (two-step: closeAndLock records block.number, reveal() requires block.number > lock + 1 and mixes blockhash(lock + 1)).","line":122,"path":"contracts/src/CourierSkins.sol","reproduction":"Deploy CourierSkins with seedCommit = keccak256(abi.encode(7)), any validator; owner calls setSaleOpen(true). A contract (see test/scratch/Probe.t.sol, contract Sniper) is given the secret 7 and, in the block where the reveal will land, computes s = keccak256(abi.encode(7, blockhash(block.number-1))), then for k = 0..9 computes design = _designAt(_shuffle(s, totalMinted + k)) and calls mint(k+1) if design >= 11 (legendary). Run in forge: vm.roll(b) with b from 10 upward until the sniper finds a legendary within reach; at b where it does, it minted through token 8 predicting design 12 (Hologram Courier). The owner then calls reveal(7) in the same block. Expected per guarantee 2: the minter could not have known the design. Actual: designOf(8) == 12, exactly as predicted (scratch test test_SniperPicksALegendaryInTheRevealBlock passes, logging 'sniped id 8 design 12'). The owner variant is the same sequence from one owner-controlled contract: mint the chosen IDs then reveal(secret) in a single transaction.","severity":"low","snippet":"        if (saleOpen) {\n            saleOpen = false;\n            emit SaleOpen(false);\n        }\n        uint256 s = uint256(keccak256(abi.encode(secret, blockhash(block.number - 1))));","title":"reveal() is allowed while the sale is open, so whoever knows the secret in the reveal block can mint skins whose designs are already decided"},{"citation":"resolved","description":"The owner-powers paragraph in AUDIT.md section 9 states 'The owner can't mint for free'. That is not what the code enforces: price is owner-settable with no floor, so the owner can set it to 0, mint up to 10 per call (repeatedly), and set it back; and since mint payments go to the owner-chosen treasury, even a paid mint by the owner is a transfer to itself. While price is 0 every other wallet can also mint for free until it is raised again. This is not a permission bypass (the owner is trusted and the sale can be ended at any time), but the documented trust model should state that the owner can take skins at no cost before the reveal, and buyers should not rely on the price as a cost borne by the team. If a guarantee is wanted, add a minimum price constant or make price immutable after the sale opens.","line":329,"path":"contracts/src/CourierSkins.sol","reproduction":"Owner calls setSaleOpen(true), setPrice(0), mint{value: 0}(10), setPrice(0.004 ether). Expected per AUDIT.md: not possible. Actual: owner holds 10 skins, treasury balance is 0 (scratch test test_OwnerMintsFreeViaPriceZero passes). Any wallet calling mint{value:0}(10) between the two setPrice calls also receives free skins.","severity":"info","snippet":"    function setPrice(uint256 price_) external onlyOwner {\n        price = price_;\n        emit PriceSet(price_);\n    }","title":"AUDIT.md says the owner can't mint for free, but setPrice(0) (or setTreasury(owner)) makes mints free for the owner"},{"citation":"resolved","description":"Mint payments are forwarded in full, so the 'nothing is ever left in the contract' guarantee holds for mint(). But ETH can still be pushed into the contract without calling it, via SELFDESTRUCT of a contract created in the same transaction (still allowed after EIP-6780) or as a coinbase/beacon-style transfer. CourierSkins has no receive/fallback and no owner sweep, so such ETH is stuck forever. Impact is only the sender's loss; listed because the brief asks that nothing be left in the contract. A one-line owner-only sweep of address(this).balance to treasury would close it without touching mint accounting.","line":112,"path":"contracts/src/CourierSkins.sol","reproduction":"Deploy CourierSkins; deploy `contract Burner { constructor(address payable to) payable { selfdestruct(to); } }` with 1 ether and to = address(skins). Expected: no ETH can rest in the contract. Actual: address(skins).balance == 1 ether and no function can move it; a plain call with value reverts (scratch test test_ForceSentEthIsStuck passes).","severity":"info","snippet":"        Address.sendValue(payable(treasury), msg.value);","title":"ETH force-sent to CourierSkins can never leave it: no receive, fallback or sweep"},{"citation":"resolved","description":"claimable() returns count 0 for three reasons: the wallet already claimed its cap (NothingToClaim), the pool cannot cover one stamp, or dailyLimit for that day is reached. The last two both surface as PoolEmpty, so a front-end or a player seeing PoolEmpty will conclude the pool needs a top-up when the pool may be nearly full. The payout invariants themselves hold (the player keeps the right to claim once the day's limit is raised or the reward is lowered; neither dailyLimit nor the pool is ever exceeded). Add a DailyLimitReached error chosen when dailyLimit != 0 && paidFor[day] + rewardPerStamp > dailyLimit.","line":132,"path":"contracts/src/StampHunt.sol","reproduction":"StampHunt with reward 10e18, maxStampsPerDay 24, dailyLimit 50e18, pool 1000e18. A wallet with an open office claims a voucher (day, 5): paid 50e18. The same wallet claims a voucher (day, 6). Expected: an error saying the day's limit is reached. Actual: revert PoolEmpty() while pool() == 950e18 (scratch test test_DailyLimitHitRevertsPoolEmpty passes).","severity":"info","snippet":"            revert PoolEmpty();","title":"claim() reverts PoolEmpty when it is the daily limit that is exhausted, not the pool"},{"citation":"resolved","description":"stamp and postOffice are immutable in StampHunt and the constructor only rejects the zero address. The script does not check that OFFICE has code, that it answers offices(address) with the nine-field tuple the hunt decodes, or that PostOffice(OFFICE).stamp() == STAMP (deployments/robinhood.json holds the live values but the script reads raw env instead). HUNT_MAX_PER_DAY is cast to uint16 silently. A wrong deployment cannot be repaired: the only remedy is to withdraw the pool and redeploy, and the signer and front-end must be repointed. Suggest: read the addresses from deployments/robinhood.json, require OFFICE.code.length > 0 and PostOffice(OFFICE).stamp() == STAMP, and require HUNT_MAX_PER_DAY <= type(uint16).max before broadcasting.","line":29,"path":"contracts/script/DeployHunt.s.sol","reproduction":"Run DeployHunt with STAMP=0x26D80804D0FA2F4598369E0062bFC9536d115D82 and OFFICE=0xBaB43AF01C32a952ff14B395D026cf1874932Bc5 (the two live addresses swapped). Expected: the script refuses. Actual: StampHunt deploys; every claim() then reverts (the token has no offices(address)) and deposit() reverts (the post office has no transferFrom). Separately, HUNT_MAX_PER_DAY=70000 deploys with maxStampsPerDay == 4464 (70000 mod 65536) and HUNT_MAX_PER_DAY=65560 deploys with 24 while looking like a typo-free high cap.","severity":"info","snippet":"            uint16(vm.envOr(\"HUNT_MAX_PER_DAY\", uint256(24))),","title":"DeployHunt takes STAMP, OFFICE and HUNT_MAX_PER_DAY unchecked into immutables; a swapped or oversized value deploys cleanly and bricks every claim"}],"hash":"a34a06af69ae832c9e2616f2a2afe3ebcd44bc45a190c6336fde3d507acd6c11","nodeId":"5acbdfe4-af4b-49b4-8117-9a0e4ceb12cd","outcome":"completed","summary":"The review is complete and the findings are in `.imd-findings.json` at the repository root.\n\n**Outcome.** No critical, high or medium defect in CourierSkins, StampHunt or the two deploy scripts. One low finding and four informational notes, each with a reproduction I ran in `test/scratch/`.\n\n**What I verified**\n- All 28 assigned tests pass, the full suite passes (125 project tests plus my 8 scratch tests), and both fork tests pass against OpenSea's live registry on Robinhood Chain.\n- The Feistel shuffle is a bijection on 0..998 for 256 fuzzed seeds, so no design can exceed its supply.\n- Under the real registry, holders can move skins to and from contract wallets and to themselves, while an approved EOA or marketplace without the fee check is refused. Every transfer path reaches the single `_update` override.\n- Supply, exact payment, same-call forwarding to the treasury, fixed 5% rate, validator never zero or non-contract, and consistent on-chain JSON all hold.\n- StampHunt: signatures are bound to chain, contract, caller, day, count and deadline. Cumulative vouchers cannot overpay, per-wallet, per-day and pool caps are enforced with rounding down, and the live post office returns exactly the nine-field tuple the hunt decodes.\n\n**Findings written**\n- **Low.** `reveal()` works while the sale is open. In the reveal block the seed is already determined, so the owner (atomically) or anyone who sees the reveal calldata before sequencing can mint the specific IDs that land on legendaries. Demonstrated: token 8 sniped as design 12 under secret 7. Fix: require the sale to have been closed in an earlier block.\n- **Info.** AUDIT.md claims the owner cannot mint for free, but `setPrice(0)` and an owner-chosen treasury make that untrue. Trust-model wording, not a bypass.\n- **Info.** Force-sent ETH is stuck in CourierSkins; there is no sweep.\n- **Info.** `claim()` reverts `PoolEmpty` when the daily limit is what ran out.\n- **Info.** DeployHunt does not validate the immutable STAMP and OFFICE addresses or the uint16 cast of the per-day cap; a swapped pair deploys cleanly and bricks every claim.\n\nNo proof tests were attached because nothing reached high severity. Scratch tests remain under `contracts/test/scratch/` and no project file was changed.","treeHash":null,"usage":{"cachedInputTokens":794310,"inputTokens":322,"model":"claude-fable-5-1","outputTokens":41730,"runtime":"claude","turns":21,"wallClockMs":685966}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"fcb71e606c933181","findings":[{"citation":"resolved","description":"Guarantee 2 (AUDIT.md section 9) says that before `reveal` nobody, minter or owner, can know or influence any token's design. The reveal derives `seed` from the committed secret and `blockhash(block.number - 1)`. Both are known to the owner before the reveal transaction is sent: the secret is theirs, and blockhash(block.number - 1) is already final when a transaction for the current block is built. Nothing forces the sale to be closed before the hash is fixed: `reveal` closes the sale itself, and `setSaleOpen(false)` can be undone with `setSaleOpen(true)` at any time before the reveal. So while the sale is open the owner can compute the exact seed a reveal would set, run `_shuffle`/`_designAt` for the next IDs `totalMinted+1 ...`, mint those IDs (paying `price x quantity` to the treasury, which the owner sets, so at no net cost), and reveal under the same hash. The two steps only have to share the value of blockhash(block.number - 1). On Ethereum that means the same block (a bundle). On Robinhood Chain it needs nothing special: it is an Arbitrum Nitro chain, where `block.number` is the parent-chain (L1) block number and `blockhash` is ArbOS's recorded hash for that number. Observed live on chain 4663 across 17 consecutive L2 blocks (86010433 to 86010450): block.number stayed 26171747 and blockhash(block.number - 1) stayed 0x9710ba2d...6821e7. Every L2 block of one L1 block (12 s or more, longer when the sequencer lags L1) shares the hash, so an ordinary mint transaction followed by an ordinary reveal transaction a few seconds later is enough. The owner can repeat the prediction for every L1 block window during the sale, and act in the first window where the upcoming IDs include Moonwedges (3 designs of 13 in 999) or Golden/Hologram Couriers, so it is not limited to 'waiting for a blockhash it likes' for tokens it already holds, which is the risk AUDIT.md accepts: it chooses which unminted deck slots it takes before anyone else can. The 'Known and accepted' entry and the NatSpec on `reveal` ('the owner can't pre-compute the designs either') understate this. Fix, preserving the design: refuse a reveal that can share its blockhash with an open sale. For example record `saleClosedAt = block.number` in `setSaleOpen(false)` (and reset it on reopen), and in `reveal` require `!saleOpen && block.number > saleClosedAt`, so the hash mixed in is one that did not exist while anyone could still mint. Document that on Arbitrum-family chains `block.number` is the L1 number, so 'earlier block' means an earlier L1 block.","line":126,"path":"contracts/src/CourierSkins.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {CourierSkins} from \"src/CourierSkins.sol\";\n\n/// Stands in for the transfer validator: any contract will do, transfers are not exercised here.\ncontract AllowAll {\n    function validateTransfer(address, address, address, uint256) external view {}\n}\n\n/// The owner knows the secret. `reveal` mixes it with blockhash(block.number - 1), a value that is already\n/// public when the reveal is sent (and, on Robinhood Chain, stays the same for every L2 block of one L1\n/// block). While the sale is still open the owner can therefore compute the seed the reveal will produce,\n/// read off the designs of the next token IDs, mint exactly those, and reveal under the same hash.\n/// Guarantee 2 says nobody, owner included, can know or influence a design before the reveal.\n///\n/// Fails on the current code: the owner's prediction comes true. Passes once a reveal can't share its\n/// blockhash with an open sale (e.g. reveal requires the sale to have been closed in an earlier block).\ncontract RevealCherryPickTest is Test {\n    CourierSkins skins;\n    address owner = makeAddr(\"owner\");\n    address alice = makeAddr(\"alice\");\n    uint256 constant PRICE = 0.004 ether;\n    uint256 constant SECRET = 0xC0FFEE;\n\n    function setUp() public {\n        // The treasury is the owner's own wallet, so the owner's mints cost it nothing but gas.\n        skins = new CourierSkins(owner, owner, PRICE, keccak256(abi.encode(SECRET)), address(new AllowAll()), \"x/\");\n        vm.prank(owner);\n        skins.setSaleOpen(true);\n        vm.deal(alice, 10 ether);\n        vm.deal(owner, 10 ether);\n        // The public has minted 100 skins so far.\n        for (uint256 i = 0; i < 10; i++) {\n            vm.prank(alice);\n            skins.mint{value: PRICE * 10}(10);\n        }\n        vm.roll(block.number + 10);\n    }\n\n    // --- the contract's own shuffle, replicated off-chain by the owner ---\n\n    function _designAt(uint256 slot) internal view returns (uint256 design) {\n        uint256 end;\n        for (design = 0; design < 16; design++) {\n            end += skins.supplyOf(design);\n            if (slot < end) return design;\n        }\n        revert(\"bad slot\");\n    }\n\n    function _shuffle(uint256 x, uint256 s) internal pure returns (uint256) {\n        do {\n            uint256 l = x >> 5;\n            uint256 r = x & 31;\n            for (uint256 i = 0; i < 4; i++) {\n                (l, r) = (r, l ^ (uint256(keccak256(abi.encode(s, i, r))) & 31));\n            }\n            x = (l << 5) | r;\n        } while (x >= 999);\n        return x;\n    }\n\n    function test_OwnerCanMintTheDesignsItPredictedThenRevealUnderTheSameHash() public {\n        // What the owner can compute before sending anything: the seed the reveal would set right now.\n        uint256 s = uint256(keccak256(abi.encode(SECRET, blockhash(block.number - 1))));\n        if (s == 0) s = 1;\n        uint256 first = skins.totalMinted() + 1; // 101\n        uint256[10] memory predicted;\n        for (uint256 i = 0; i < 10; i++) predicted[i] = _designAt(_shuffle(first + i - 1, s));\n\n        // Sale still open: the owner mints the IDs it just read off, then reveals under the same hash.\n        vm.startPrank(owner);\n        skins.mint{value: PRICE * 10}(10);\n        bool revealed;\n        try skins.reveal(SECRET) {\n            revealed = true;\n        } catch {}\n        vm.stopPrank();\n\n        if (!revealed) return; // a fixed contract refuses a reveal that shares its hash with an open sale\n\n        bool allPredicted = true;\n        for (uint256 i = 0; i < 10; i++) {\n            if (skins.ownerOf(first + i) != owner || skins.designOf(first + i) != predicted[i]) allPredicted = false;\n        }\n        assertFalse(allPredicted, \"owner minted ten skins whose designs it knew before the reveal\");\n    }\n}","reproduction":"State: sale open, 100 tokens minted by the public, treasury == owner. Owner computes s = keccak256(abi.encode(secret, blockhash(block.number - 1))) (s = 1 if 0), then predicted[i] = _designAt(_shuffle(100 + i, s)) for i = 0..9, mints 10 (IDs 101..110) and calls reveal(secret) under the same blockhash(block.number - 1). Expected per guarantee 2: the owner could not have known those designs, so the prediction should not hold (or the reveal should be refused). Actual: seed == s and designOf(101..110) equals the prediction for all ten. The attached test test/scratch/RevealCherryPick.t.sol fails on the current code with 'owner minted ten skins whose designs it knew before the reveal' and passes once a reveal cannot share its hash with an open sale.","severity":"medium","snippet":"        uint256 s = uint256(keccak256(abi.encode(secret, blockhash(block.number - 1))));","title":"Owner can predict the reveal seed while the sale is open, mint the token IDs it knows are legendary, then reveal under the same blockhash"},{"citation":"resolved","description":"The deploy script narrows the HUNT_MAX_PER_DAY environment value from uint256 to uint16 with a plain cast, which truncates instead of reverting. Any value that is a multiple of 65536 becomes 0, and e.g. 70000 becomes 4464. Neither the StampHunt constructor nor `setLimits` (contracts/src/StampHunt.sol:171, `maxStampsPerDay = maxStampsPerDay_;`) rejects 0, and 0 does not mean 'no limit' there as it does for `dailyLimit` (the two limits have opposite zero semantics in the same setter). With maxStampsPerDay == 0, `claimable` computes cap = 0, returns (0, 0), and `claim` reverts NothingToClaim for every voucher, however large the pool. A deployment with a mistyped value would go live paying nobody, with no revert at deploy time to flag it. Fix: in the script, read the value as uint256 and `require(v > 0 && v <= type(uint16).max)` before casting (or use `vm.envOr` into a uint16-typed helper and check it); in StampHunt, revert on maxStampsPerDay_ == 0 in the constructor and `setLimits`, or define 0 as 'no cap' consistently with `dailyLimit`.","line":29,"path":"contracts/script/DeployHunt.s.sol","reproduction":"Input: HUNT_MAX_PER_DAY=65536 (or 131072) in the environment of `forge script script/DeployHunt.s.sol`. Expected: the script refuses an out-of-range setting, or the hunt pays up to 65536 stamps per wallet per day. Actual: uint16(65536) == 0, StampHunt deploys with maxStampsPerDay == 0, and after the owner deposits 1000e18 a valid voucher claim(day, 3, deadline, sig) from a wallet with an open office reverts NothingToClaim; `claimable(player, day, 3)` returns (0, 0). The same state is reachable on a live hunt with `setLimits(0, 10_000e18)`.","severity":"low","snippet":"            uint16(vm.envOr(\"HUNT_MAX_PER_DAY\", uint256(24))),","title":"HUNT_MAX_PER_DAY is truncated to uint16 unchecked, and maxStampsPerDay = 0 is accepted everywhere, which silently stops every payout"},{"citation":"resolved","description":"`claim` has two error paths when `claimable` returns a zero count: NothingToClaim when the wallet's per-day cap is used up, and PoolEmpty otherwise. `claimable` also returns zero when `dailyLimit - paidFor[day]` is below one reward, so a claim that is blocked only by the day's global limit reverts PoolEmpty while the pool may hold thousands of $STAMP. The accounting is right (nothing is overpaid), but a front end or support process that reads the error as 'the pool needs a top-up' will act on the wrong cause, and a deposit will not unblock the claim. Fix: add a `DailyLimitReached` error and pick it when `dailyLimit != 0 && dailyLimit - paidFor[day] < rewardPerStamp`.","line":132,"path":"contracts/src/StampHunt.sol","reproduction":"State from the existing test fixture: reward 10e18, pool 1000e18, owner calls setLimits(24, 50e18); alice and bob have open offices. alice claims a voucher for 5 stamps today: paid 50e18, paidFor[today] == 50e18. bob then claims a valid voucher for 4 stamps the same day. Expected: a revert that says the day's limit is reached. Actual: revert PoolEmpty() while pool() == 950e18.","severity":"info","snippet":"            revert PoolEmpty();","title":"claim reverts PoolEmpty when it is the daily limit, not the pool, that is exhausted"},{"citation":"resolved","description":"Every reveal in the suite goes through `_reveal()`, which rolls five blocks first, and `testFuzz_DifferentSeedsGiveAValidDeal` only checks that designs are in range. No test mints and reveals under one blockhash(block.number - 1), computes the seed off-chain before the reveal, or asserts anything about who can know a design before the reveal. That is the edge the first finding lives on. Other math edges that are untested: `maxStampsPerDay == 0` and `setLimits` lowering the cap below a wallet's `claimed` count; `dailyLimit` or the pool not being a multiple of `rewardPerStamp` (remainder is correctly left unpaid, but nothing asserts it); a `claim` exactly at `deadline` (accepted, `>` comparison) and exactly at the UTC day rollover; `mint` with price == 0. Adding these keeps the stated guarantees checked rather than assumed.","line":55,"path":"contracts/test/CourierSkins.t.sol","reproduction":"Run `forge test --match-contract CourierSkinsTest -vvvv` and inspect the trace of any reveal: block.number is always setUp's number + 5 at the reveal and no test mints in the reveal's block. A test that mints then reveals without `vm.roll`, as in test/scratch/RevealCherryPick.t.sol, is the missing case.","severity":"info","snippet":"        vm.roll(block.number + 5);","title":"Tests never reveal in the same block as a mint, so the reveal-with-open-sale path is unexercised"}],"hash":"b9eb1bc00e78425f86ba6c85c14094bbb9cbd6a5ed0af4535e2ec4db941b81c6","nodeId":"c859f0fe-0770-4973-975d-599c39785452","outcome":"completed","summary":"The review is complete. All 125 tests pass on the pinned commit, the findings file holds four entries, and nothing outside test/scratch/ was changed.\n\n**Outcome.** One substantive defect against the stated guarantees, one deployment boundary, two informational notes. The arithmetic itself is sound: I found no rounding, overflow, downcast or limit defect in `mint`, `_shuffle`, `_designAt`, `royaltyInfo`, `claimable` or `claim`.\n\n**Findings written to `.imd-findings.json`:**\n\n- **Medium, CourierSkins.sol:126.** The reveal seed mixes the secret with `blockhash(block.number - 1)`, which the owner already knows when sending the reveal, and the sale can still be open at that moment. The owner can compute the seed, read off the designs of the next token IDs, mint those (paying their own treasury), and reveal under the same hash. On Robinhood Chain this needs no same-block bundle: I observed live that `block.number` is the Ethereum L1 number and the blockhash stays constant across 17 consecutive L2 blocks, so two ordinary transactions a few seconds apart suffice. This exceeds the accepted \"owner waits for a blockhash it likes\" because the owner chooses unminted slots. Proof test attached; it fails now and passes under a fix that refuses a reveal sharing its hash with an open sale, which I verified with a temporary patch that is restored.\n- **Low, DeployHunt.s.sol:29.** The unchecked uint16 cast of HUNT_MAX_PER_DAY turns 65536 into 0, and neither the constructor nor `setLimits` rejects 0. With maxStampsPerDay 0 every claim reverts NothingToClaim however full the pool. The two limits have opposite zero semantics in the same setter.\n- **Info, StampHunt.sol:132.** A claim blocked only by `dailyLimit` reverts PoolEmpty while the pool is full.\n- **Info, CourierSkins.t.sol:55.** No test reveals in the same block as a mint, and several math edges are untested.\n\n**Verified clean.** Supply cap and exact payment with ETH forwarded in the same call. Feistel shuffle with cycle-walking is a bijection on 0..998: a full mint matched every design's supply for five seeds including 0 and max. Every transfer path reaches `_update`, the validator must have code, the royalty rate is a constant, and the fork test against OpenSea's real registry on chain 4663 passed. The ERC721-C selector and interface id compute correctly. Hunt caps hold under reward and limit changes, the uint16 cast in `claimable` is bounded, and the PostOffice struct layout matches the interface.","treeHash":null,"usage":{"cachedInputTokens":1383648,"inputTokens":546,"model":"claude-fable-5-1","outputTokens":37341,"runtime":"claude","turns":28,"wallClockMs":707120}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"63c29c49a249ab7e","findings":[{"citation":"resolved","description":"Guarantee 2 (AUDIT.md section 9) says that before `reveal` nobody, owner included, can know or influence any token's design. The seed is keccak256(secret, blockhash(block.number - 1)). Both inputs are available to the owner before `reveal` runs: it holds the secret, and the previous block hash is public once that block exists. The sale is only closed inside `reveal` itself (line 122), so there is no point at which minting has stopped but the entropy is not yet fixed. The owner can therefore, in one block (or atomically from an owner contract), replicate `_shuffle`/`_designAt` off-chain, learn the designs of tokens totalMinted+1 .. 999, mint as many of the next IDs as it takes to capture the legendaries among them (its payment goes to its own treasury, so the mint is free to it), and then call `reveal` so the predicted seed is the one that is fixed. On Robinhood Chain (Arbitrum-style) `block.number` and `blockhash(block.number - 1)` stay constant for a whole parent-chain block window, which makes landing mint and reveal on the same entropy routine even with two plain EOA transactions. AUDIT.md accepts that the owner may wait for a blockhash it likes; this is a stronger power that the accepted item does not cover: the owner chooses *which tokens it receives* with full knowledge of their designs, so unminted legendaries are never really at stake for public minters once the sale has slowed. Minimal fix preserving the design: make minting impossible once the reveal entropy is knowable. For example, record `closedAt = block.number` in `setSaleOpen(false)`, require `!saleOpen && block.number > closedAt` in `reveal` (so the hash used was produced after the sale closed, and nobody could mint between), or alternatively have `reveal` close the sale and record `revealBlock`, with the seed drawn later from `blockhash(revealBlock)` within the 256-block window. Either way a token can no longer be minted after the seed can be computed.","line":126,"path":"contracts/src/CourierSkins.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {CourierSkins} from \"src/CourierSkins.sol\";\n\ncontract PassValidator {\n    function validateTransfer(address, address, address, uint256) external view {}\n}\n\n/// Guarantee 2 of AUDIT.md section 9: before `reveal` nobody, owner included, can know or influence any\n/// token's design. The seed is keccak256(secret, blockhash(block.number - 1)), and both inputs are known to\n/// the owner while the sale is still open (the sale is only closed inside `reveal` itself). So the owner can\n/// compute the seed, predict the designs of the next unminted IDs, mint exactly those, and reveal in the same\n/// block. This test fails on the current code (the prediction matches every minted token) and passes once\n/// minting is impossible with the reveal entropy in hand (e.g. `reveal` requires the sale to have been closed\n/// in an earlier block, or the seed is drawn from a block after the sale closed).\ncontract OwnerPredictsDesignsTest is Test {\n    CourierSkins skins;\n    address owner = makeAddr(\"owner\");\n    address treasury = makeAddr(\"treasury\");\n    address alice = makeAddr(\"alice\");\n    uint256 constant PRICE = 0.004 ether;\n    uint256 constant SECRET = 0x5EC12E7;\n\n    function setUp() public {\n        skins = new CourierSkins(owner, treasury, PRICE, keccak256(abi.encode(SECRET)), address(new PassValidator()), \"x/\");\n        vm.prank(owner);\n        skins.setSaleOpen(true);\n        vm.deal(alice, 1 ether);\n        vm.deal(owner, 1 ether);\n        vm.prank(alice);\n        skins.mint{value: PRICE * 5}(5); // the public sale: tokens 1..5\n        vm.roll(block.number + 10);\n    }\n\n    // The owner's off-chain replica of the contract's shuffle, keyed by the seed it can already compute.\n    function _shuffle(uint256 s, uint256 x) internal pure returns (uint256) {\n        do {\n            uint256 l = x >> 5;\n            uint256 r = x & 31;\n            for (uint256 i = 0; i < 4; i++) {\n                (l, r) = (r, l ^ (uint256(keccak256(abi.encode(s, i, r))) & 31));\n            }\n            x = (l << 5) | r;\n        } while (x >= 999);\n        return x;\n    }\n\n    function _designAt(uint256 slot) internal view returns (uint256 design) {\n        uint256 end;\n        for (design = 0; design < 16; design++) {\n            end += skins.supplyOf(design);\n            if (slot < end) return design;\n        }\n        revert();\n    }\n\n    function test_OwnerCannotMintKnowingTheDesigns() public {\n        // What the owner knows before anyone else: the secret, and this block's reveal entropy.\n        uint256 s = uint256(keccak256(abi.encode(SECRET, blockhash(block.number - 1))));\n        if (s == 0) s = 1;\n        uint256 first = skins.totalMinted() + 1; // 6\n        uint256[10] memory predicted;\n        for (uint256 i = 0; i < 10; i++) predicted[i] = _designAt(_shuffle(s, first + i - 1));\n\n        // The owner mints the next ten (paying its own treasury) and reveals in the same block.\n        vm.startPrank(owner);\n        (bool minted,) = address(skins).call{value: PRICE * 10}(abi.encodeCall(CourierSkins.mint, (10)));\n        (bool revealed,) = address(skins).call(abi.encodeCall(CourierSkins.reveal, (SECRET)));\n        vm.stopPrank();\n\n        bool allMatch = minted && revealed && skins.seed() != 0;\n        for (uint256 i = 0; allMatch && i < 10; i++) {\n            if (skins.designOf(first + i) != predicted[i]) allMatch = false;\n        }\n        assertFalse(allMatch, \"owner minted tokens whose designs it had already computed, then revealed\");\n    }\n}","reproduction":"State: sale open, tokens 1..5 minted by a player, secret S known to the owner, commit = keccak256(abi.encode(S)). In block N the owner computes s = keccak256(abi.encode(S, blockhash(N-1))) (s==0 -> 1) and predicted[i] = _designAt(_shuffle_s(5+i)) for i=0..9 (the designs of tokens 6..15). Then in the same block the owner calls mint{value: 0.04 ether}(10) and reveal(S). Expected (guarantee 2): the owner cannot have known the designs of the tokens it minted, i.e. either the reveal is refused or designOf(6..15) do not equal predicted. Actual: both calls succeed, seed == s, and designOf(6+i) == predicted[i] for every i. The proof test test/scratch/OwnerPredictsDesigns.t.sol asserts exactly this and fails on the current code with 'owner minted tokens whose designs it had already computed, then revealed'.","severity":"medium","snippet":"        uint256 s = uint256(keccak256(abi.encode(secret, blockhash(block.number - 1))));","title":"Owner can compute the seed while the sale is still open and mint the IDs it already knows are rare"},{"citation":"resolved","description":"tokenURI returns 'Courier Skin #n / Sealed / mystery.png' before the reveal and the design, rarity, type and picture after it, so the reveal rewrites the metadata of every minted token (1..totalMinted). OpenSea and other marketplaces that cache tokenURI re-read it automatically only when the collection emits the ERC-4906 events (MetadataUpdate(uint256) / BatchMetadataUpdate(uint256,uint256)) and advertises interface 0x49064906. CourierSkins emits only Revealed and SaleOpen in reveal, only ImageBaseSet and ContractURIUpdated in setImageBase (which changes every token's image), and supportsInterface(0x49064906) is false. Consequence: listed skins keep showing as sealed with the mystery picture after the reveal until each holder manually refreshes, and the Design/Rarity traits (which drive pricing and the enforced royalty's value) are invisible on the marketplace. Fix: emit BatchMetadataUpdate(1, totalMinted) in reveal (and in setImageBase when totalMinted > 0), and return true for 0x49064906 in supportsInterface.","line":128,"path":"contracts/src/CourierSkins.sol","reproduction":"Deploy, open the sale, mint 3 tokens, roll forward, call reveal(secret) under vm.recordLogs(). Expected: a BatchMetadataUpdate(1, 3) (or three MetadataUpdate) log and supportsInterface(0x49064906) == true. Actual: the only logs are SaleOpen(false) and Revealed(seed) (topic0 0x4e02dfe6... and 0x15120e52...), tokenURI(1) has changed, and supportsInterface(0x49064906) returns false. Confirmed by test_NoMetadataUpdateEventOnReveal in test/scratch/Probe.t.sol.","severity":"low","snippet":"        emit Revealed(seed);","title":"reveal and setImageBase change every token's metadata but emit no ERC-4906 event, so marketplaces keep the sealed metadata"},{"citation":"resolved","description":"The per-wallet daily cap is read as a uint256 from the environment and narrowed with an explicit uint16() cast, which truncates instead of reverting. A mistyped or over-large value deploys a hunt with a cap nobody intended, and the only signal is the LimitsSet-equivalent constructor state. StampHunt.setLimits can repair it afterwards, but the deployment log prints nothing about the cap. Fix: read it into a uint256, require it <= type(uint16).max (and > 0), and log it, or use vm.envUint with an explicit bound check before the cast.","line":29,"path":"contracts/script/DeployHunt.s.sol","reproduction":"Run DeployHunt with HUNT_MAX_PER_DAY=65560 (an obvious typo of 24 with extra digits) or 70000. Expected: the script reverts or deploys with that cap. Actual: uint16(65560) == 24 and uint16(70000) == 4464; the hunt is deployed with maxStampsPerDay 24 and 4464 respectively, with no error.","severity":"low","snippet":"            uint16(vm.envOr(\"HUNT_MAX_PER_DAY\", uint256(24))),","title":"DeployHunt silently truncates HUNT_MAX_PER_DAY to uint16"},{"citation":"resolved","description":"claimable() returns count 0 both when the pool cannot cover one stamp and when dailyLimit - paidFor[day] cannot. claim() distinguishes only 'nothing left on this voucher' (NothingToClaim) from everything else, which it reports as PoolEmpty. A client that reads PoolEmpty as 'wait for a top-up' will tell players to retry after a deposit, which does nothing for a day whose limit is spent; and the owner reading the error may deposit unnecessarily. Fix: add a DailyLimitReached error, raised when dailyLimit != 0 and dailyLimit - paidFor[day] < rewardPerStamp, before falling back to PoolEmpty.","line":132,"path":"contracts/src/StampHunt.sol","reproduction":"Hunt with reward 10e18, maxStampsPerDay 24, dailyLimit 50e18, pool 1000e18, two wallets with open offices. Alice claims a 5-stamp voucher for today: paid 50e18, paidFor[today] == 50e18, pool 950e18. Bob claims a valid 1-stamp voucher for today. Expected: a revert that says the day's limit is reached. Actual: revert PoolEmpty() while pool() == 950e18. Confirmed by test_DailyLimitRevertsPoolEmpty in test/scratch/Probe.t.sol.","severity":"info","snippet":"            revert PoolEmpty();","title":"claim reverts PoolEmpty when it is the daily limit, not the pool, that is exhausted"},{"citation":"resolved","description":"Guarantee 1 says nothing is ever left in the contract. mint forwards exactly msg.value and there is no receive/fallback, so ordinary payments never accumulate; but ETH can still be placed in the contract by selfdestruct (a contract created and destroyed in the same transaction still transfers its balance post-Cancun) or by pre-funding the deterministic address before deployment, and there is no sweep. The amount at stake is whatever a third party chooses to waste, so this is informational; a one-line change (forward address(this).balance instead of msg.value) keeps the invariant exactly.","line":112,"path":"contracts/src/CourierSkins.sol","reproduction":"vm.deal(address(skins), 1 wei) (or selfdestruct-send 1 wei), then a normal mint of 1 at 0.004 ether. Expected under guarantee 1: address(skins).balance == 0 afterwards. Actual: treasury receives 0.004 ether and address(skins).balance stays 1 wei forever. Confirmed by test_ForcedEthStuck in test/scratch/Probe.t.sol.","severity":"info","snippet":"        Address.sendValue(payable(treasury), msg.value);","title":"ETH forced into CourierSkins is unrecoverable (the contract forwards msg.value, not its balance)"},{"citation":"resolved","description":"Section 9 lists among the things the owner can't do: 'mint for free'. setPrice has no floor and no timing restriction, and mint only checks msg.value == price * quantity, so the owner can set the price to 0, mint, and restore it, with PriceSet events as the only trace. Independently, every mint payment goes to the owner-controlled treasury, so the owner's mints cost it nothing even at full price. This is not a bypass of any check in the code (setPrice is a documented owner power) and the sale is blind, so the main consequence is that the trust assumption in AUDIT.md is stated too strongly for minters reading it. Combined with the medium finding above, it is also what makes the owner's knowledge-based mint free. Fix: either document the power as accepted, or add a minimum price / a one-way 'price locked' flag the owner sets before opening the sale.","line":330,"path":"contracts/src/CourierSkins.sol","reproduction":"Owner calls setPrice(0); then mint{value: 0}(10) ten times (100 tokens); then setPrice(0.004 ether). Expected per AUDIT.md: an owner mint always costs price x quantity. Actual: 100 tokens minted for 0 ETH, the public price unchanged afterwards.","severity":"info","snippet":"        price = price_;","title":"AUDIT.md says the owner can't mint for free, but setPrice(0) (or paying its own treasury) makes every owner mint free"},{"citation":"resolved","description":"The fork tests prove that under OpenSea's live registry a holder can move a skin and an unauthorized operator cannot. There is no test that an authorized sale (the registry's beforeAuthorizedTransfer flow from OpenSea's signed zone) actually succeeds through CourierSkins._update, so the only evidence that marketplace sales will work at all is the mock StrictValidator in the unit suite; if the live registry expected a different caller/operator shape the collection would be unsellable rather than unprotected. Bijectivity of _shuffle is asserted for a single seed (test_FullMintMatchesEverySupply); the fuzz test only checks designOf < 16. During this review a 12-run fuzz of the full deal over random secrets and reveal blocks (testFuzz_Bijection in test/scratch/Probe.t.sol) passed, so the property holds, but it is worth keeping in the suite. Also untested: safeTransferFrom with data through the validator, claim on a past day, and setLimits lowering maxStampsPerDay below a wallet's claimed count (which correctly yields NothingToClaim).","line":409,"path":"contracts/test/CourierSkins.t.sol","reproduction":"Run forge test --match-contract CourierSkinsForkTest --fork-url https://robinhood.drpc.org: both tests pass (verified during this review), but no test performs a transfer by a non-holder operator that the live registry authorizes. Expected: a test that impersonates the registry's authorizer, calls beforeAuthorizedTransfer for a token, and shows transferFrom by that operator succeeds. Actual: none.","severity":"info","snippet":"    /// Seaport, or any approved operator, can't move a skin outside OpenSea's fee-checked flow.","title":"Test gaps: authorized marketplace path is only exercised against the mock, and shuffle bijectivity against one seed"}],"hash":"ffa0e2ed42ce92e5ed21483e71c77b6f7673554916620be83e7b514a430e1c67","nodeId":"738cdcae-0a0c-44de-9942-4f971931f1ae","outcome":"completed","summary":"The review is complete. The findings file holds seven entries, and no source file was changed.\n\n**Outcome.** Both contracts hold their stated guarantees under every path I traced except one: the reveal entropy is knowable while the sale is still open. The remaining findings are low or informational.\n\n**What was verified clean.** Supply cap, exact payment and same-call forwarding to the treasury. The Feistel shuffle with cycle-walking is a bijection on the 999-card deck, confirmed by a twelve-seed full-deck fuzz. Every transfer between non-zero addresses reaches the validator through `_update`, the validator cannot be zero or an EOA, and the royalty rate is a constant. The fork tests against OpenSea's live registry on Robinhood Chain pass. For StampHunt, the EIP-712 voucher binds the caller, chain and contract, the per-wallet, per-day and pool caps are enforced in `claimable`, the office gate matches the live PostOffice getter shape, and the contract touches only its own pool. All 125 project tests pass.\n\n**Findings, by severity.**\n- **Medium, `CourierSkins.reveal`:** the seed is keccak of the secret and the previous block hash, and the sale closes only inside `reveal`. The owner can compute the seed, predict the designs of the next unminted IDs, mint the rare ones (paying its own treasury) and reveal in the same block. The proof test fails on the current code and passes with a trial fix requiring the sale to be closed in an earlier block.\n- **Low, `CourierSkins`:** reveal and image-base changes rewrite every token's metadata but emit no ERC-4906 event and do not advertise the interface, so marketplaces keep showing sealed skins.\n- **Low, `DeployHunt`:** the per-wallet cap is narrowed with a truncating cast, so a mistyped value deploys silently with a wrong cap.\n- **Info:** `claim` reports PoolEmpty when the daily limit is what ran out; force-sent ETH is unrecoverable; AUDIT.md's \"owner can't mint for free\" is contradicted by `setPrice(0)` and by payments landing in the owner's treasury; and the authorized marketplace sale path is only tested against the mock validator.\n\n**Artifacts.** The findings are in `.imd-findings.json` at the repository root. The proof test and probe tests are under `contracts/test/scratch/`.","treeHash":null,"usage":{"cachedInputTokens":1121853,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":49930,"runtime":"claude","turns":26,"wallClockMs":912663}}],"verification":[]}