{"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":"bc7bebed-1f29-4835-8df2-c99c398c0632","kind":"audit","nodes":[{"acceptedSubmissionHash":"530011b3cbe4319273110669ee69787ac153b8f03a6ab19bc45dde9009fea357","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":"65fee161c5dd04c18bf11e380372d850f285fdd68d7aa830a1b9333514361307","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":"220bdc28adc69b626868313e90323c8fe56a3789d142c30220fe8023857d6083","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":"219cc6d22f480e924451a9d3fed827005be32de9da3c592d01dbf3e8dfe036b6","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":"3c992377d71b7cac16fef379ed45bf03d3478d9fbe6290160ce50b1e177d144e","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":"SECURITY AUDIT — BMC ILLUMINATI PASS V3\n\nRepository:\nhttps://github.com/0xSt3w/launch-1356-project-bmc-illuminati-pass\n\nBranch: security/vrf-recovery\n\nPrimary contract:\nsrc/experimental/BMCIlluminatiPassCandidateV3.sol\n\nTests:\ntest/BMCIlluminatiPassCandidateV3.t.sol\n\nDeployment simulation:\nscript/DeployBMCIlluminatiPassV3DryRun.s.sol\n\nPerform an adversarial, read-only security audit of Candidate V3 before Ethereum Mainnet deployment.\n\nReview:\n1. Chainlink VRF v2.5 integration, gas lane, subscription configuration and coordinator compatibility.\n2. Single-request randomness design and permanent reveal-stall risks.\n3. BMC burnFrom accounting, irreversible token burns and reentrancy.\n4. Genesis and Standard NFT rarity distribution and inventory uniqueness.\n5. Sale deadlines, early closure and unsold NFTs.\n6. Genesis IPFS metadata integrity and immutability.\n7. Constructor permissions, deployment safety and potential denial of service.\n\nRun Foundry tests, adversarial tests and Ethereum Mainnet fork checks where possible.\n\nProduce a security report with severity-ranked findings, exact source locations, reproduction steps, recommended fixes and a clear Mainnet readiness verdict.\n\nIMPORTANT: Audit only. Do not modify files, deploy contracts, broadcast transactions, sign messages or initiate additional paid jobs. Do not claim independent certification.\n\nThe existing tests report 80 passed, 0 failed. Verify these results independently.","parentJobId":null,"planHash":"0be9d25abb4f6d49ae05eb9164f4a740aa15ffe7008354df7c27246a4f3c53a1","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"bc7bebed-1f29-4835-8df2-c99c398c0632","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":"51025","feedbackHash":"753f17196cb43f56482d9b151c955cc0bd4474b397497e2882c000125879c754","nodeKey":"audit_economics","submissionHash":"530011b3cbe4319273110669ee69787ac153b8f03a6ab19bc45dde9009fea357","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51511","feedbackHash":"b9cef85d4281f7b0761abd7fcbbdb796ed7ceded7bf153e8c012b2e77bf17d58","nodeKey":"audit_flow","submissionHash":"65fee161c5dd04c18bf11e380372d850f285fdd68d7aa830a1b9333514361307","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52254","feedbackHash":"7bc52f314da8109dd061d98a9789df13938e2cb5c897cc0aa58ccc8a7112daf5","nodeKey":"audit_judge","submissionHash":"220bdc28adc69b626868313e90323c8fe56a3789d142c30220fe8023857d6083","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52216","feedbackHash":"5cd3d7e22fe4ffcd1ab4d1008807029d7e1189d13fe53acb79365f1062b2e190","nodeKey":"audit_math","submissionHash":"219cc6d22f480e924451a9d3fed827005be32de9da3c592d01dbf3e8dfe036b6","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51514","feedbackHash":"b86ef7fb35669104c74b2cf29e49b7dc84b2e1161cb471c3cbd3a1108ae45c30","nodeKey":"audit_permissions","submissionHash":"3c992377d71b7cac16fef379ed45bf03d3478d9fbe6290160ce50b1e177d144e","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"4937006774128b4142b32fd82067ff28d4d47992d32ed92d65eae1013383621b","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"47f3603854a893a3","findings":[{"citation":"unresolved","description":"(Branch security/vrf-recovery, commit bf2a63c.) Seam between the boundary and the invariant. `_finalize` always starts from `remaining = 460 - revealedCount`, whatever the number of mystery tokens actually sold. When the sale closes at the deadline with N < 460 mystery mints, the Genesis outcomes left in the 460 - N unsold slots are never minted. The number of mystery Genesis that exist is then hypergeometric(460, 37, N) rather than 37, and the Standard count behaves the same way. On-chain metadata still states permanently that each token is 'One of 77 Genesis pieces in a 500-piece collection' (line 459) or 'One of 423 Standard pieces in a 500-piece collection' (line 485). `remainingMysteryGenesis()` and `remainingMysteryStandard()` keep reporting outcomes that can no longer be minted. Each token's odds stay fair at 37/460 in expectation, so the problem is the supply and rarity claims, not the fairness of the draw. Fix: either keep the design and make the metadata and views depend on supply (for example 'Genesis supply = 40 + mysteryGenesisRevealed' once all reveals finish, and return 0 from the remaining* views after `saleClosed`), or, as an explicit scope decision, shrink the pool to the sold count with a guaranteed Genesis quota. The latter changes the economics.","line":324,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Mock VRF, seed = 42: mint 40 Genesis, then 46 mystery tokens (ids 41-86). Warp to the deadline, call requestSeed(), fulfill with word 42, then finalizeReveals(100). Expected, per the metadata and constants: 77 Genesis in a 500-piece collection. Actual: 3 mystery Genesis, so 43 Genesis exist out of totalSupply() 86. remainingMysteryGenesis() returns 34 and remainingMysteryStandard() returns 380 forever, although minting is closed. Every Genesis token's tokenURI still says 'One of 77 Genesis pieces in a 500-piece collection'. (Verified with a local Foundry scratch test.)","severity":"low","snippet":"        uint256 remaining = MYSTERY_SUPPLY - revealedCount;\n        uint256 word = globalSeed;\n        uint256 r = uint256(keccak256(abi.encode(word, tokenId))) % remaining;","title":"Partial sell-through: reveal still draws from the full 460-slot pool, so the 77 Genesis / 423 Standard supply that metadata and views report is never reached"},{"citation":"unresolved","description":"Off-by-one at the phase boundary. `mintMystery` opens only when `totalMinted == 40`, and Phase 1 shares the single immutable `saleDeadline` with Phase 2. Nothing extends the deadline or opens Phase 2 early. If Genesis demand stops one short (39 sold, 39,000,000 BMC burned) before the deadline, Phase 2 never opens. After the deadline `closeSale` freezes the collection at 39 tokens, and Genesis artworks 40-77 and all 423 Standard pieces are never minted. Phase 2 can only open if one buyer pays 1,000,000 BMC for the 40th Genesis. Fix, preserving the two phases: give Phase 1 its own deadline after which Phase 2 opens (and unsold Genesis ids 1-40 move into the mystery pool or are dropped explicitly), or let `mintMystery` open once `block.timestamp >= genesisPhaseDeadline`.","line":232,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Deploy with deadline = now + 30 days. Mint 39 Genesis. mintMystery() reverts GenesisPhaseNotOver. Warp 30 days: mintGenesis() reverts GenesisPhaseOver and mintMystery() reverts GenesisPhaseNotOver. Expected: the mystery phase is sellable, or the 460 positions are clearly marked as closed. Actual: zero mystery sales are possible, and remainingMysteryMints() still returns 460 after the sale is over. (Verified with a local Foundry scratch test.)","severity":"low","snippet":"        if (totalMinted < GENESIS_PHASE_SUPPLY) revert GenesisPhaseNotOver();","title":"Mystery phase is gated on all 40 Genesis selling: at 39/40 Genesis by the deadline, all 460 mystery positions become permanently unsellable"},{"citation":"unresolved","description":"The deadline is validated only as strictly in the future. `closeSale` (line 245) requires `totalMinted == MAX_SUPPLY || block.timestamp >= saleDeadline`, and `requestSeed` and `finalizeReveals` depend on it. A deadline such as type(uint64).max, or a mistyped value in milliseconds, means a sale that does not reach 500 can never close. No seed can then be requested, and every mystery buyer's BMC is burned for a token that stays 'Pending' forever. At the other extreme, closingTime = now + 1 gives a sale that is already over. The dry-run script uses 30 days, but the contract has no guard and no owner recovery path, because the owner has no setters by design. Fix: bound the deadline in the constructor, for example `closingTime >= block.timestamp + 1 days && closingTime <= block.timestamp + 365 days`.","line":191,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Deploy with closingTime = type(uint64).max. Mint 40 Genesis and 1 mystery, then warp 3650 days. requestSeed() reverts 'not closed yet' and finalizeReveals(1) reverts 'seed pending'. Expected: the sale closes and token 41 can be revealed. Actual: token 41 stays Pending permanently after 350,000 BMC was burned. (Verified with a local Foundry scratch test.)","severity":"low","snippet":"        require(closingTime > block.timestamp, \"deadline must be future\");","title":"Constructor accepts any closingTime > now: a far-future deadline makes a partially sold collection impossible to close, so reveals stall permanently"},{"citation":"unresolved","description":"`requestSeed` can succeed once. If that request is never fulfilled, nothing in the contract recovers: there is no re-request after a timeout, no way to change the coordinator or key hash, and no IVRFMigratableConsumerV2Plus. `finalizeReveals` needs `seedReceived`, so all sold mystery tokens stay Pending. Causes outside the contract's control: the coordinator owner deregisters the 500 gwei proving key 0x3fd2...f10b after the request; the coordinator is deprecated; the subscription stays unfunded until the node stops trying. Mainnet state checked on 2026-10-12: subscription 2785...2985 holds 0 LINK and 0.02 ETH native, has no consumers, and is owned by 0x83027AD9d0D5374335086c539dADD1db983441B0. The code comments document this risk. It is reported because up to 161,000,000 BMC of irreversible mystery burns depend on a single external fulfillment. Fix, keeping the single-seed design: allow `requestSeed` again when `!seedReceived && block.timestamp > seedRequestedAt + T`, for example 24h. `rawFulfillRandomWords` already accepts only `seedRequestId` and keeps the first word, so a re-request cannot be used to pick a seed, because no seed exists yet.","line":255,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Mock coordinator: mint 40 Genesis and 1 mystery, warp to the deadline, call requestSeed(), and never fulfill. Warp 365 days. requestSeed() reverts 'seed already requested' and finalizeReveals(1) reverts 'seed pending'. Expected: a recovery path after a bounded wait. Actual: token 41 stays Pending permanently. (Verified locally. A mainnet-fork run showed requestSeed reverts until the subscription owner adds the contract as a consumer, then succeeds.)","severity":"low","snippet":"        require(seedRequestId == 0, \"seed already requested\");","title":"Single, non-retryable seed request: once seedRequestId is set, an unfulfilled request blocks every mystery reveal forever"},{"citation":"unresolved","description":"`remainingMysteryMints()` (line 393) and `remainingSupply()` (line 384) check only `totalMinted`. `currentPrice()` and `genesisPhaseActive()` also check `saleClosed` and `saleDeadline`. After the deadline these views report inventory that can never be minted, which misleads frontends and integrators: website/app.js reads sale state. Fix: return 0 when `saleClosed || block.timestamp >= saleDeadline`.","line":393,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Mint 39 Genesis and warp past saleDeadline. Expected: remainingMysteryMints() returns 0, matching currentPrice() == 0. Actual: it returns 460, and remainingSupply() returns 461.","severity":"info","snippet":"        return totalMinted < GENESIS_PHASE_SUPPLY ? MYSTERY_SUPPLY : MAX_SUPPLY - totalMinted;","title":"Supply views ignore the deadline and closure: remainingMysteryMints/remainingSupply report sellable inventory after the sale has ended"}],"hash":"219cc6d22f480e924451a9d3fed827005be32de9da3c592d01dbf3e8dfe036b6","nodeId":"84ee9ace-d5d7-4225-9a12-ba717619835e","outcome":"completed","summary":"I found no critical, high or medium defects in Candidate V3. There are four low findings and one informational one. Even so, I wouldn't call it ready for mainnet without a decision on the issues below and some setup work. All five findings are in `.imd-findings.json`.\n\n**Where the code is.** The checkout I was given is `main` (17aa386), which doesn't contain V3. I audited `security/vrf-recovery` (bf2a63c) in a temporary worktree outside the repo and removed it afterwards. The file paths and line numbers in the findings refer to that branch, so a check against `main` won't find them. No repository files were changed.\n\n**Test results.** The \"80 passed, 0 failed\" claim is only partly true:\n- Plain `forge test` gives **80 passed, 1 failed**. The failure is `test/BMCMainnetFork.t.sol`, which stops with \"Ethereum mainnet fork required\" when no RPC is set.\n- With `--fork-url` set to a public mainnet RPC, that fork test passes. But it tests the old `BMCIlluminatiPass`, not V3.\n- The V3 suite has only 9 tests and no fork test. It doesn't cover partial sales, deadline edge cases or a seed that never arrives.\n\n**My own V3 mainnet fork run.** I wrote a scratch test against mainnet state on 2026-10-12:\n- The real BMC token has 18 decimals, and each mint burned exactly the right amount (40 Genesis + 1 mystery burned exactly 40,350,000 BMC).\n- The 500 gwei key hash is registered on the real VRF coordinator.\n- `requestSeed()` reverts until the subscription owner adds the contract as a consumer, then succeeds.\n- The subscription currently holds 0 LINK and 0.02 ETH, and has no consumers.\n- The dry-run deploy script simulates cleanly and broadcasts nothing.\n\n**Findings:**\n1. **Low – unsold mystery tokens break the supply figures (line 324).** The reveal always draws from the full 460-slot pool, however many mystery tokens sold. With seed 42, 40 Genesis and 46 mystery sold, only 3 mystery Genesis come out: 43 Genesis exist out of 86 tokens. The on-chain metadata still says \"One of 77 Genesis pieces\", and the remaining-Genesis/Standard views report outcomes that can never be minted. Each buyer's odds are still fair.\n2. **Low – one unsold Genesis blocks the whole mystery phase (line 232).** Mystery minting only opens once all 40 Genesis sell. If sales stop at 39 by the deadline, none of the 460 mystery positions can ever be sold.\n3. **Low – the deadline has no bounds (line 191).** The constructor only checks that it's in the future. With `type(uint64).max`, a sale that doesn't sell out can never close, so no seed can be requested and every mystery token stays \"Pending\" after its BMC is burned.\n4. **Low – the single seed request can't be retried (line 255).** If the one request is never fulfilled (for example, Chainlink retires the key or the coordinator), every reveal is blocked forever. The code comments acknowledge this; I've suggested a re-request after a timeout.\n5. **Info – supply views ignore the deadline (line 393).** After the sale ends, `remainingMysteryMints()` can still return 460.\n\n**Math checks with no defect found:**\n- The shuffle draws without repeats: a full 460-token run had no duplicates.\n- The bias from the modulo draw is negligible.\n- Every `uint16` cast is in range.\n- `totalBurned` can't exceed its cap.\n- Deadline comparisons use `>=` consistently across functions.\n- Reveal results are fixed by the seed and token order, so the caller can't influence them.\n\n**Before mainnet:**\n- Add the contract as a consumer on the subscription.\n- Decide what should happen to unsold Genesis and Standard pieces.\n- Add deadline bounds and a way to retry the seed request.\n\nThis is a review, not an independent certification. I ran the math, boundary and numerical-gap checks in depth. I covered the other areas only through the steps above; I didn't do a full pass on reentrancy or access control.","treeHash":null,"usage":{"cachedInputTokens":1095358,"inputTokens":30,"model":"claude-opus-5-5","outputTokens":21039,"runtime":"claude","turns":19,"wallClockMs":260737}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"584bd638e7e022f0","findings":[{"citation":"unresolved","description":"Tree note: this file exists only on branch security/vrf-recovery (commit bf2a63c), the branch the audit was commissioned for; the pinned checkout (17aa386, main) has no src/experimental/. Line 255 and the snippet were re-verified against the branch blob on 2026-10-12 (git show origin/security/vrf-recovery:src/experimental/BMCIlluminatiPassCandidateV3.sol). The attached proof compiles and fails only on that branch ('seed already requested'); on the pinned main tree it cannot compile because the import target is absent, so it cannot drive a revision here. The in-tree counterpart of this defect class (no retry for an unfulfilled VRF request in src/BMCIlluminatiPass.sol) is reported separately as the production-contract finding at src/BMCIlluminatiPass.sol:321. Scope note: the pinned checkout (17aa386, main) does not contain src/experimental/* or the V3 tests; this finding refers to branch security/vrf-recovery at commit bf2a63c, reproduced in a separate worktree of that commit. requestSeed() succeeds exactly once: after seedRequestId is set, line 255 reverts 'seed already requested' for the life of the contract, and rawFulfillRandomWords() only accepts that one id. finalizeReveals() requires seedReceived, so all sold mystery tokens (up to 460, 161,000,000 BMC burned irreversibly through burnFrom) depend on one external fulfilment. Chainlink VRF v2.5 does not keep requests alive indefinitely: the subscription docs (docs.chain.link/vrf/v2-5/overview/subscription, fetched 2026-10-12) state 'If your balance is below the minimum, your requests remain pending for up to 24 hours before they expire.' The coordinator does not check the balance at request time: on a mainnet fork at block 26176938, once the subscription owner adds the pass as consumer, requestRandomWords succeeds although subscription 2785...2985 holds 0 LINK and 0.02 ETH native (minimum for the 500 gwei lane at 500k callback gas is ~0.434 ETH). Because requestSeed is callable by anyone the moment block.timestamp >= saleDeadline (or by the 500th minter from inside onERC721Received), the operator cannot sequence 'fund, then request'; a griefer or eager holder can fire the request into an underfunded subscription, or during a gas spike above the lane cap lasting more than 24h, and the request expires. The coordinator then keeps the pending commitment (cancelSubscription reverts PendingRequestExists, verified on the fork), so the subscription cannot even be recycled. The header comment acknowledges the risk ('A permanently unfulfilled request may permanently prevent reveals') and test_OnlyOneSeedRequestEvenAfterSevenDays enshrines it as expected behaviour, so relaxing the single-request rule is a scope decision; but shipping it to mainnet means a single missed 24h window destroys the reveal for every buyer. Merged from audit_math (2be12ea3), audit_flow (b7224882) and audit_permissions (0840ae1c); the two attached proofs both fail on this code with 'seed already requested'. Fix (keeps first-delivered-word-is-permanent): store seedRequestedAt; allow requestSeed() again when !seedReceived && block.timestamp >= seedRequestedAt + TIMEOUT (e.g. 2-7 days); record every issued id in mapping(uint256 => bool) issued and accept delivery for any issued id, keeping the first word (the seedReceived guard already does this). Optionally restrict the first request window to the owner so funding can be verified before the request is made.","line":255,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {ERC20Burnable} from \"@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol\";\nimport {VRFV2PlusClient} from \"@chainlink/contracts/src/v0.8/vrf/dev/libraries/VRFV2PlusClient.sol\";\nimport {BMCIlluminatiPassCandidateV3} from \"src/experimental/BMCIlluminatiPassCandidateV3.sol\";\n\n/// @dev Self-contained BMC stand-in: 18 decimals, OpenZeppelin burnFrom semantics.\ncontract ProofBMC is ERC20, ERC20Burnable {\n    constructor() ERC20(\"BMC Illuminati\", \"BMC\") {}\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\n/// @dev Minimal VRF v2.5 coordinator stand-in with a registered proving key and request expiry.\n///      `expire` models the Chainlink node dropping a request that stayed unpaid for 24 hours\n///      (docs.chain.link, VRF v2.5 subscription overview: \"requests remain pending for up to\n///      24 hours before they expire\"). An expired request is never delivered.\ncontract ProofCoordinator {\n    bytes32 public constant VALID_KEY = keccak256(\"registered-key\");\n\n    struct Req {\n        address consumer;\n        uint32 callbackGasLimit;\n        bool fulfilled;\n        bool expired;\n    }\n\n    uint256 public nextRequestId = 1000;\n    mapping(uint256 => Req) public requests;\n\n    function s_provingKeys(bytes32 key) external pure returns (bool exists, uint64 maxGas) {\n        return (key == VALID_KEY, key == VALID_KEY ? uint64(500 gwei) : uint64(0));\n    }\n\n\n    function getSubscription(uint256)\n        external\n        view\n        returns (uint96 balance, uint96 nativeBalance, uint64 reqCount, address owner, address[] memory consumers)\n    {\n        return (0, 1 ether, 0, address(this), new address[](0));\n    }\n\n    function requestRandomWords(VRFV2PlusClient.RandomWordsRequest calldata req) external returns (uint256 id) {\n        id = nextRequestId++;\n        requests[id] = Req({consumer: msg.sender, callbackGasLimit: req.callbackGasLimit, fulfilled: false, expired: false});\n    }\n\n    function expire(uint256 requestId) external {\n        requests[requestId].expired = true;\n    }\n\n    function fulfill(uint256 requestId, uint256 word) external returns (bool ok) {\n        Req storage r = requests[requestId];\n        require(r.consumer != address(0), \"unknown request\");\n        require(!r.fulfilled && !r.expired, \"cannot fulfil\");\n        r.fulfilled = true;\n        uint256[] memory words = new uint256[](1);\n        words[0] = word;\n        (ok,) = r.consumer.call{gas: r.callbackGasLimit}(\n            abi.encodeWithSignature(\"rawFulfillRandomWords(uint256,uint256[])\", requestId, words)\n        );\n    }\n}\n\n/// @title Proof: the sole seed request has no recovery path once the Chainlink node drops it.\n/// @notice Fails on the current code: after the only request expires unfulfilled, every one of the\n///         sold mystery tokens stays pending forever and `requestSeed()` reverts with\n///         \"seed already requested\". Passes once the contract allows a fresh request for an\n///         unfulfilled seed after a liveness timeout (first delivered word still permanent).\ncontract V3SeedRequestStallProof is Test {\n    uint256 internal constant REREQUEST_TIMEOUT = 7 days;\n\n    ProofBMC bmc;\n    ProofCoordinator coord;\n    BMCIlluminatiPassCandidateV3 pass;\n    address alice = makeAddr(\"alice\");\n    uint64 deadline;\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        deadline = uint64(block.timestamp + 30 days);\n        bmc = new ProofBMC();\n        coord = new ProofCoordinator();\n        pass = new BMCIlluminatiPassCandidateV3(\n            address(this), address(bmc), address(coord), deadline, 123, coord.VALID_KEY(), 500_000, 3, true, \"bafyTestCID\"\n        );\n        bmc.mint(alice, 201_000_000 ether);\n        vm.startPrank(alice);\n        bmc.approve(address(pass), type(uint256).max);\n        for (uint256 i; i < 40; ++i) pass.mintGenesis();\n        for (uint256 i; i < 5; ++i) pass.mintMystery();\n        vm.stopPrank();\n    }\n\n    function test_ExpiredSoleSeedRequestCanBeReplaced() public {\n        vm.warp(deadline);\n        uint256 first = pass.requestSeed();\n        assertEq(pass.seedRequestId(), first);\n\n        // The subscription was not funded in time; the node drops the request after 24h.\n        coord.expire(first);\n        vm.warp(block.timestamp + REREQUEST_TIMEOUT + 1);\n        assertFalse(pass.seedReceived());\n        assertEq(pass.revealedCount(), 0);\n\n        // Expected: a liveness path exists and a second request can be opened.\n        // Actual (current code): revert \"seed already requested\"; tokens 41..45 are pending forever.\n        uint256 second = pass.requestSeed();\n        assertTrue(second != 0 && second != first, \"a fresh request must be opened\");\n\n        // The replacement request is delivered and finalization proceeds.\n        assertTrue(coord.fulfill(second, 777));\n        assertTrue(pass.seedReceived());\n        pass.finalizeReveals(10);\n        assertEq(pass.revealedCount(), 5);\n        assertEq(uint256(pass.revealStatusOf(41)), uint256(BMCIlluminatiPassCandidateV3.RevealStatus.Revealed));\n    }\n}","reproduction":"State: 40 Genesis + 5 mystery minted (1,750,000 BMC burned for ids 41-45), block.timestamp >= saleDeadline. 1) anyone calls requestSeed() -> seedRequestId = R. 2) the subscription is below Chainlink's minimum; the node never fulfils R and drops it after 24h. 3) 7 days later anyone calls requestSeed(). Expected: a fresh request can be opened (or any recovery path exists) so tokens 41-45 can be revealed. Actual: revert 'seed already requested'; finalizeReveals(10) reverts 'seed pending'; tierOf(41..45) == Pending and revealStatusOf == AwaitingRandomness forever. Run: forge test --match-path test/scratch/V3SeedRequestStall.t.sol on branch security/vrf-recovery -> FAIL 'seed already requested'. Re-run 2026-10-12 in a worktree of bf2a63c: Proof_0840ae1c8a6c and Proof_b72248823b93 both FAIL 'seed already requested'.","severity":"high","snippet":"        require(seedRequestId == 0, \"seed already requested\");","title":"V3: the sole VRF seed request is permissionless and non-retryable; an expired or never-fulfilled request leaves every mystery token Pending forever"},{"citation":"resolved","description":"In the production contract setVRFConfig (lines 196-217) only range-checks its inputs: line 204 rejects subscriptionId == 0 and keyHash == bytes32(0) and bounds the gas and confirmation values, then stores whatever else was passed with configured = true. Nothing asks the coordinator whether keyHash is a registered proving key (VRFCoordinatorV2_5 exposes s_provingKeys(bytes32) for exactly this) or whether subscriptionId exists and lists this contract as a consumer. mintMystery (lines 241-262) likewise burns 350,000 BMC (irreversible burnFrom) and only then calls requestRandomWords. The real coordinator does not validate the key hash at request time ('requests for invalid keyHashes will simply not be fulfilled'), so a mistyped or retired lane produces a request that is accepted, paid for by the buyer's burn, and never served; with an unknown subscription id the request reverts InvalidSubscription so no mint is possible until the owner fixes the config, which is the benign case. The harmful case is the accepted-but-unfulfillable lane: every token minted under it is stuck forever (no re-request exists), and through the strict in-order finalization reported at src/BMCIlluminatiPass.sol:321 it also blocks every later token, so a single owner typo or a lane retired by Chainlink between two mints permanently destroys the reveal for the rest of the collection. The owner can re-point setVRFConfig for future mints but cannot rescue the stuck tokens. The trigger is a privileged misconfiguration rather than an attacker, hence medium; the NatSpec claim that requests simply 'remain pending while the subscription is unfunded' (lines 36-39) understates it. Candidate V3 on branch security/vrf-recovery (bf2a63c, src/experimental/BMCIlluminatiPassCandidateV3.sol line 204, not in the pinned tree) carries the same defect in a worse form: the constructor performs the identical range check, freezes subscriptionId and keyHash with no setter, and the first contact with the coordinator is requestSeed() after the sale has closed and up to 201,000,000 BMC has been burned. Any of (a) unregistered keyHash -> 'unregistered gas lane', (b) unknown subscriptionId -> InvalidSubscription(), (c) the subscription owner (0x83027AD9d0D5374335086c539dADD1db983441B0 on mainnet, an EOA distinct from the contract owner) never calling addConsumer -> InvalidConsumer(subId, pass), or (d) removeConsumer / cancelSubscription before requestSeed (allowed while no request is pending) makes requestSeed revert on every call forever with no remedy for any party. Verified on a mainnet fork (block 26176938) with the exact DryRun constants: deploy, 40 Genesis + 1 mystery (40,350,000 BMC burned) succeed, then requestSeed() reverts InvalidConsumer because the live subscription has 0 consumers; a second fork test where the subscription owner cancels before the deadline ends in a permanent InvalidSubscription(). The branch test test_UnknownGasLaneCannotRequestSeed treats that permanent revert as correct behaviour. Merged from audit_permissions (3a7cd7c7, 430299f3, d9951611 key-hash part) and audit_economics (d80bb015). Fix, production contract: in setVRFConfig staticcall s_provingKeys(keyHash) on VRF_COORDINATOR and revert InvalidVRFConfig unless exists (the attached proof passes with exactly this two-line check); optionally also call getSubscription(subscriptionId) and require address(this) among the consumers so a mint can never burn into a request the coordinator will reject. Fix, V3 (keeps immutability): perform the same s_provingKeys and getSubscription checks in the constructor, and in mintMystery() or requestSeed() require that address(this) is a registered consumer with a non-zero balance before burning; name the subscription owner as a trusted party in the README, or let the contract create and own its subscription (createSubscription + addConsumer(address(this)), fundable by anyone).","line":204,"path":"src/BMCIlluminatiPass.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {ERC20Burnable} from \"@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol\";\nimport {VRFV2PlusClient} from \"@chainlink/contracts/src/v0.8/vrf/dev/libraries/VRFV2PlusClient.sol\";\nimport {BMCIlluminatiPass} from \"src/BMCIlluminatiPass.sol\";\n\n/// @dev Plain 18-decimal ERC20Burnable, same semantics as mainnet BMC.\ncontract ProofBMC is ERC20, ERC20Burnable {\n    constructor() ERC20(\"BMC Illuminati\", \"BMC\") {}\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\n/// @dev Minimal VRF v2.5 coordinator stand-in. Like the real VRFCoordinatorV2_5 it exposes the\n///      registered proving keys through `s_provingKeys(bytes32)` and does NOT validate the key hash\n///      in requestRandomWords (\"requests for invalid keyHashes will simply not be fulfilled\").\n///      A request made under an unregistered key can never be fulfilled.\ncontract ProofCoordinator {\n    bytes32 public constant REGISTERED_LANE = keccak256(\"registered lane\");\n    uint256 public nextRequestId = 1000;\n    mapping(uint256 => bytes32) public laneOf;\n\n    function s_provingKeys(bytes32 key) external pure returns (bool exists, uint64 maxGas) {\n        return (key == REGISTERED_LANE, key == REGISTERED_LANE ? uint64(500 gwei) : uint64(0));\n    }\n\n    function requestRandomWords(VRFV2PlusClient.RandomWordsRequest calldata req) external returns (uint256 id) {\n        id = nextRequestId++;\n        laneOf[id] = req.keyHash;\n    }\n\n    function canEverFulfill(uint256 requestId) external view returns (bool) {\n        return laneOf[requestId] == REGISTERED_LANE;\n    }\n}\n\n/// @title Proof: setVRFConfig accepts a key hash the coordinator does not know, so mystery mints burn\n///        BMC into VRF requests that can never be fulfilled.\n/// @notice Fails on the current code: setVRFConfig(1, keccak256(\"typo\"), ...) succeeds although the\n///         coordinator reports the key as unregistered, and the next mintMystery() burns 350,000 BMC\n///         and commits a request under that key. Passes once the contract checks the key hash against\n///         the coordinator's s_provingKeys (at configuration time, or at the latest before burning in\n///         mintMystery) so that no BMC can be burned for an unfulfillable request.\ncontract ProofUnvalidatedKeyHashTest is Test {\n    ProofBMC bmc;\n    ProofCoordinator coord;\n    BMCIlluminatiPass pass;\n    address alice = makeAddr(\"alice\");\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        bmc = new ProofBMC();\n        coord = new ProofCoordinator();\n        pass = new BMCIlluminatiPass(address(this), address(bmc), address(coord));\n        pass.setGenesisImagesCID(\"bafyProofCID\");\n\n        bmc.mint(alice, 201_000_000 ether);\n        vm.startPrank(alice);\n        bmc.approve(address(pass), type(uint256).max);\n        for (uint256 i; i < 40; ++i) pass.mintGenesis();\n        vm.stopPrank();\n    }\n\n    function test_UnregisteredKeyHashCannotBurnBMCIntoUnfulfillableRequest() public {\n        bytes32 typo = keccak256(\"typo\");\n        (bool exists,) = coord.s_provingKeys(typo);\n        assertFalse(exists, \"precondition: coordinator does not know this key\");\n\n        // Expected (fixed code): the owner cannot configure a lane the coordinator does not serve.\n        try pass.setVRFConfig(1, typo, 500_000, 3, true) {}\n        catch {\n            return; // rejected at configuration time: nothing can be burned under the bad key\n        }\n\n        // Actual (current code): the configuration is accepted. The next mystery mint must then be\n        // refused, otherwise 350,000 BMC is burned for a request no node will ever fulfil.\n        uint256 burnedBefore = pass.totalBurned();\n        vm.prank(alice);\n        try pass.mintMystery() returns (uint256 tokenId, uint256 requestId) {\n            assertEq(tokenId, 41);\n            assertFalse(coord.canEverFulfill(requestId), \"request was made under the unregistered key\");\n            assertEq(pass.totalBurned() - burnedBefore, 350_000 ether, \"350,000 BMC burned for it\");\n            assertEq(uint256(pass.revealStatusOf(41)), uint256(BMCIlluminatiPass.RevealStatus.AwaitingRandomness));\n            assertTrue(false, \"mintMystery burned BMC into a VRF request under an unregistered key hash\");\n        } catch {\n            // fixed code: the mint refuses to burn under an unregistered key\n        }\n    }\n}","reproduction":"Production contract, in tree: owner calls setGenesisImagesCID, then setVRFConfig(1, keccak256('typo'), 500000, 3, true) against a coordinator whose s_provingKeys(keccak256('typo')) returns (false, 0). Expected: setVRFConfig reverts (or at least the next mintMystery refuses to burn). Actual: setVRFConfig succeeds; after 40 Genesis mints a buyer's mintMystery() burns 350,000 BMC (totalBurned grows by 350000e18), token 41 is AwaitingRandomness, and the emitted request carries the unregistered key, so no node will ever fulfil it and tokens 42+ are blocked behind it. Run: forge test --match-path test/scratch/ProofMainUnvalidatedKeyHash.t.sol -> FAIL 'mintMystery burned BMC into a VRF request under an unregistered key hash'; with a s_provingKeys check added to setVRFConfig the same test passes (verified in a throwaway worktree). V3, branch only: deploy with keyHash = keccak256('typo-key-hash') or subscriptionId = 999. Expected: deployment reverts. Actual: deployment succeeds; after the sale closes requestSeed() reverts 'unregistered gas lane' (resp. InvalidSubscription()) on every call. Fork: deploy with the DryRun constants, mint 40 Genesis + 1 mystery, warp 30 days, requestSeed() -> InvalidConsumer(27852459...562985, pass). Proof_3a7cd7c7b5f2 re-run on a worktree of bf2a63c on 2026-10-12: two tests FAIL 'next call did not revert as expected'.","severity":"medium","snippet":"            subscriptionId == 0 || keyHash == bytes32(0) || callbackGasLimit < MIN_CALLBACK_GAS_LIMIT","title":"VRF key hash and subscription id are accepted without any check against the coordinator: setVRFConfig takes an unregistered lane and mystery mints then burn BMC into requests no node will ever fulfil "},{"citation":"resolved","description":"This is the contract in the pinned checkout (17aa386) and the fallback if V3 is not deployed. Each mystery mint opens its own VRF request (mintMystery, line 253) and finalizeReveals() advances only in strict mint order, stopping at the first token whose status is not RandomnessStored (line 321). There is no re-request, timeout or skip, so one request the coordinator never fulfils freezes that token and every token minted after it, even when their own words have already been delivered. Ways a request becomes permanently unfulfillable, none recoverable on-chain: (a) setVRFConfig accepts any non-zero keyHash and VRFCoordinatorV2_5.requestRandomWords deliberately does not validate the key hash ('requests for invalid keyHashes will simply not be fulfilled'), so a mint under a mistyped or retired lane commits a dead request; correcting the config afterwards only affects future requests; (b) the subscription runs dry for more than 24 hours during the 460-request sale: Chainlink's VRF v2.5 docs state 'If your balance is below the minimum, your requests remain pending for up to 24 hours before they expire', which directly contradicts the contract NatSpec at lines 38-39 and README line 64-65 ('requests remain pending while the subscription is unfunded and are fulfilled once it is funded again'); (c) Chainlink deregisters the lane's proving key while requests are pending. Impact: up to 460 pending NFTs (161,000,000 BMC burned) stay 'Pending Reveal' forever; the owner has no power to unblock them. Merged from audit_economics (ce5fcafa, with proof) and audit_permissions (d9951611). The attached proof fails on this code with 'token 42 stuck behind token 41: 2 != 3'. Fix (preserves in-order fairness): record the request timestamp per token; after a bounded window (e.g. 7 days) allow a permissionless retryRequest(tokenId) that issues a fresh request under the current config and maps the new id to the token, accepting only the newest id in rawFulfillRandomWords; or let finalizeReveals skip a token whose request has been stale for the window so delivered successors can finalize. Validate keyHash via s_provingKeys in setVRFConfig (as V3 does at request time) and correct the NatSpec and README.","line":321,"path":"src/BMCIlluminatiPass.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {ERC20Burnable} from \"@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol\";\nimport {VRFV2PlusClient} from \"@chainlink/contracts/src/v0.8/vrf/dev/libraries/VRFV2PlusClient.sol\";\nimport {BMCIlluminatiPass} from \"src/BMCIlluminatiPass.sol\";\n\n/// @dev Plain 18-decimal ERC20Burnable, same semantics as mainnet BMC.\ncontract ProofBMC is ERC20, ERC20Burnable {\n    constructor() ERC20(\"BMC Illuminati\", \"BMC\") {}\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\ninterface IProofConsumer {\n    function rawFulfillRandomWords(uint256 requestId, uint256[] calldata randomWords) external;\n}\n\n/// @dev Minimal VRF v2.5 coordinator stand-in. Like the real VRFCoordinatorV2_5, requestRandomWords\n///      does NOT validate the key hash (\"requests for invalid keyHashes will simply not be fulfilled\").\n///      Fulfilment is only possible for requests made under the registered lane; a request made under\n///      any other key hash (typo, retired gas lane, deregistered proving key) stays pending forever.\ncontract ProofCoordinator {\n    bytes32 public constant REGISTERED_LANE = keccak256(\"registered lane\");\n    uint256 public nextRequestId = 1000;\n    mapping(uint256 => address) public consumerOf;\n    mapping(uint256 => bytes32) public laneOf;\n\n    function requestRandomWords(VRFV2PlusClient.RandomWordsRequest calldata req) external returns (uint256 id) {\n        id = nextRequestId++;\n        consumerOf[id] = msg.sender;\n        laneOf[id] = req.keyHash;\n    }\n\n    function fulfill(uint256 requestId, uint256 word) external returns (bool ok) {\n        require(laneOf[requestId] == REGISTERED_LANE, \"NoSuchProvingKey\");\n        uint256[] memory words = new uint256[](1);\n        words[0] = word;\n        (ok,) = consumerOf[requestId].call{gas: 500_000}(\n            abi.encodeCall(IProofConsumer.rawFulfillRandomWords, (requestId, words))\n        );\n    }\n}\n\n/// @title Proof: one unfulfillable VRF request permanently stalls every later mystery reveal.\n/// @notice Fails on the current code: token 41's request was made under a key hash the coordinator\n///         never fulfils; tokens 42..50 have their random words stored, yet finalizeReveals() reveals\n///         none of them because it only advances in strict mint order and there is no re-request or\n///         timeout for the stuck token. Passes once a token whose own randomness has been delivered\n///         can be finalized after its predecessor's request has stayed unfulfilled for a bounded time.\ncontract ProofRevealStallTest is Test {\n    ProofBMC bmc;\n    ProofCoordinator coord;\n    BMCIlluminatiPass pass;\n    address alice = makeAddr(\"alice\");\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        bmc = new ProofBMC();\n        coord = new ProofCoordinator();\n        pass = new BMCIlluminatiPass(address(this), address(bmc), address(coord));\n        pass.setGenesisImagesCID(\"bafyProofCID\");\n        // Key hash that the coordinator accepts at request time but will never fulfil.\n        pass.setVRFConfig(1, keccak256(\"retired lane\"), 500_000, 3, true);\n\n        bmc.mint(alice, 201_000_000 ether);\n        vm.startPrank(alice);\n        bmc.approve(address(pass), type(uint256).max);\n        for (uint256 i; i < 40; ++i) pass.mintGenesis();\n        vm.stopPrank();\n    }\n\n    function test_DeliveredTokensRevealDespiteOneStuckRequest() public {\n        // Token 41: request committed under the unfulfillable lane (owner typo / lane retired later).\n        vm.prank(alice);\n        (uint256 stuckToken, uint256 stuckRequest) = pass.mintMystery();\n        assertEq(stuckToken, 41);\n\n        // Owner corrects the lane. setVRFConfig only affects future requests (contract doc, line 195).\n        pass.setVRFConfig(1, coord.REGISTERED_LANE(), 500_000, 3, true);\n\n        // Tokens 42..50: requests under the registered lane, all fulfilled by the coordinator.\n        uint256[9] memory ids;\n        for (uint256 i; i < 9; ++i) {\n            vm.prank(alice);\n            (, ids[i]) = pass.mintMystery();\n        }\n        for (uint256 i; i < 9; ++i) {\n            assertTrue(coord.fulfill(ids[i], uint256(keccak256(abi.encode(i))) ));\n        }\n        for (uint256 t = 42; t <= 50; ++t) {\n            assertEq(uint256(pass.revealStatusOf(t)), uint256(BMCIlluminatiPass.RevealStatus.RandomnessStored));\n        }\n\n        // The stuck request can never be fulfilled by the coordinator.\n        vm.expectRevert(bytes(\"NoSuchProvingKey\"));\n        coord.fulfill(stuckRequest, 1);\n\n        // Thirty days later, anyone tries to finalize. Expected: the nine tokens whose randomness was\n        // delivered are revealed. Actual (current code): zero reveals; 3,150,000 BMC burned for tokens\n        // 42..50 (and every later mystery mint) stay \"Pending Reveal\" forever.\n        vm.warp(block.timestamp + 30 days);\n        pass.finalizeReveals(100);\n        assertEq(uint256(pass.revealStatusOf(42)), uint256(BMCIlluminatiPass.RevealStatus.Revealed), \"token 42 stuck behind token 41\");\n        assertEq(pass.revealedCount(), 9, \"delivered tokens must be revealable\");\n    }\n}","reproduction":"State: Genesis phase complete (tokens 1-40), owner set VRF config with keyHash K1 that the coordinator will never fulfil. 1) alice mints token 41 -> request R1 under K1, 350,000 BMC burned. 2) owner calls setVRFConfig(.., K2 registered, ..). 3) alice mints tokens 42..50 -> R2..R10 under K2. 4) coordinator fulfils R2..R10 -> revealStatusOf(42..50) == RandomnessStored; R1 can never be fulfilled. 5) 30 days later anyone calls finalizeReveals(100). Expected: tokens 42..50 are Revealed, or some path exists to re-request randomness for token 41. Actual: finalizeReveals returns 0, revealedCount stays 0, revealStatusOf(42) == RandomnessStored, and no owner or public function can change that. Run: forge test --match-path test/scratch/ProofMainInOrderStall.t.sol on 17aa386 -> FAIL 'token 42 stuck behind token 41: 2 != 3'.","severity":"high","snippet":"        while (done < maxCount && tokenId <= totalMinted && _revealStatus[tokenId] == RevealStatus.RandomnessStored) {","title":"Production contract: strict in-order finalization lets one unfulfillable per-token VRF request permanently block every later reveal; NatSpec wrongly claims requests never expire"},{"citation":"unresolved","description":"Scope note: the pinned checkout (17aa386, main) does not contain src/experimental/* or the V3 tests; this finding refers to branch security/vrf-recovery at commit bf2a63c, reproduced in a separate worktree of that commit. The only mainnet checks in the script are that the two addresses have code (line 25) and that owner/deadline/CID round-trip. It never reads getSubscription(SUBSCRIPTION_ID) or s_provingKeys(KEY_HASH). Live mainnet state read on 2026-10-12 at block 26176938 via cast on coordinator 0xD7f86b4b8Cae7D942340FF628F82735b7a20893a: getSubscription(27852459...562985) = (balance 0 LINK, nativeBalance 20000000000000000 = 0.02 ETH, reqCount 0, owner 0x83027AD9d0D5374335086c539dADD1db983441B0, consumers []); s_provingKeys(0x3fd2fec1...ff10b) = (true, 500 gwei) so the configured lane is the 500 gwei lane; s_config shows native premium 24%. With nativePayment = true only the native balance counts; Chainlink's documented minimum before a node fulfils is lane max gas x (200,000 verification + callbackGasLimit) x 1.24 = 500 gwei x 700,000 x 1.24 = 0.434 ETH, so the subscription holds 4.6% of what the single fulfilment needs. A V3 deployed with these constants today cannot request randomness at all (InvalidConsumer, see the high finding on validation), and after addConsumer the request would sit pending and expire after 24h (see the high finding on the single request). Running the script against the fork still prints 'Script ran successfully', so the CI step gives no assurance that the deployment can ever reveal. Merged from audit_flow (c492b0c4) and audit_economics (5a85ec2c). Fix: before deploying, fund the subscription with >= 0.5 ETH native (or LINK with nativePayment=false) and add the pass as consumer; extend the script to read getSubscription and s_provingKeys and require owner == expected signer, key exists, and nativeBalance >= laneMaxGas x (200000 + callbackGasLimit) x 124/100, printing the computed minimum.","line":25,"path":"script/DeployBMCIlluminatiPassV3DryRun.s.sol","reproduction":"OWNER=0x83027AD9d0D5374335086c539dADD1db983441B0 forge script script/DeployBMCIlluminatiPassV3DryRun.s.sol:DeployBMCIlluminatiPassV3DryRun --fork-url <mainnet> -> 'Script ran successfully'. In the same block: cast call 0xD7f86b4b8Cae7D942340FF628F82735b7a20893a 'getSubscription(uint256)(uint96,uint96,uint64,address,address[])' 27852459484169864437638942739291618120641810244677084392617474192083559562985 -> 0, 20000000000000000, 0, 0x83027AD9d0D5374335086c539dADD1db983441B0, []. Expected: the dry run fails or warns that the subscription has no consumer and 0.02 ETH against a 0.434 ETH minimum. Actual: passes. Fork test: deploy with the script constants, mint 40 Genesis + 1 mystery, warp to the deadline, requestSeed() -> InvalidConsumer; after vm.prank(subscription owner) addConsumer, requestSeed() returns a request id despite the 0.02 ETH balance.","severity":"medium","snippet":"        require(BMC.code.length > 0 && COORDINATOR.code.length > 0, \"missing mainnet contract code\");","title":"Dry-run deployment hard-codes a mainnet subscription that has no consumers, 0 LINK and 0.02 ETH, and validates none of it; CI would pass while reveals are impossible"},{"citation":"unresolved","description":"Scope note: the pinned checkout (17aa386, main) does not contain src/experimental/* or the V3 tests; this finding refers to branch security/vrf-recovery at commit bf2a63c, reproduced in a separate worktree of that commit. V3's rawFulfillRandomWords only writes seedReceived and globalSeed and emits one event. Measured with the branch mock: 53,566 gas (a 60,000-gas delivery succeeds). The 500,000 value and the README note that 500k lets each callback finalize up to 4 reveals come from the per-mint design of src/BMCIlluminatiPass.sol and no longer apply. Chainlink reserves laneMaxGas x (200,000 + callbackGasLimit) x 1.24 before a node will fulfil: 0.434 ETH at 500 gwei with 500,000, versus 0.217 ETH with 150,000 (the contract's own MIN_CALLBACK_GAS_LIMIT). The oversized reserve doubles the funding the subscription must hold at close and widens the window in which the single request can expire unfunded. Merged from audit_flow (c492b0c4) and audit_economics (73f12892). Fix: set callbackGasLimit to 150,000 in the script (consider lowering MIN_CALLBACK_GAS_LIMIT toward ~120,000 and the 200 gwei lane 0x8077df51..., also registered on mainnet).","line":37,"path":"script/DeployBMCIlluminatiPassV3DryRun.s.sol","reproduction":"Deploy V3, mint 40 Genesis + 460 mystery, requestSeed(), then vm.prank(coordinator) and call rawFulfillRandomWords(req, [777]) measuring gasleft() before/after: 53,566 gas used; coord.fulfillWithGas(req, 5, 60_000) returns true and seedReceived() == true. Required minimum balance with 500,000: 500e9 x 700,000 x 124/100 = 434,000,000,000,000,000 wei; with 150,000: 217,000,000,000,000,000 wei. Expected: callback limit sized to the callback. Actual: 9x oversized.","severity":"low","snippet":"            500_000,","title":"callbackGasLimit of 500,000 is about nine times the measured V3 callback cost and doubles the subscription balance Chainlink requires before fulfilling"},{"citation":"unresolved","description":"Scope note: the pinned checkout (17aa386, main) does not contain src/experimental/* or the V3 tests; this finding refers to branch security/vrf-recovery at commit bf2a63c, reproduced in a separate worktree of that commit. Once rawFulfillRandomWords stores globalSeed, the tier of every sold mystery token is a pure function of public state (keccak256(abi.encode(globalSeed, tokenId)) % remaining walked in token-id order over the virtual Fisher-Yates), but tierOf()/tokenURI() keep reporting Pending until someone pays for finalizeReveals(): measured 85,300 gas per token, about 39M gas for 460 tokens, so several transactions by an unincentivised caller. In that window an informed buyer who replays _finalize off-chain (or eth_calls finalizeReveals) can buy exactly the 37 future Genesis tokens from holders or marketplace listings priced as mysteries. No outcome can be changed, and the pending metadata warns that 'rarity can be calculated before this metadata is finalized', so this is an information-asymmetry/UX defect rather than a randomness defect. The main-branch contract mitigates it with a gas-aware try this.finalizeReveals(batch) inside the callback; V3 kept CALLBACK_REVEAL_BATCH, GAS_PER_REVEAL and CALLBACK_GAS_RESERVE but no longer uses them. Merged from audit_flow (2e476099), audit_economics (26c15c58) and audit_permissions (58088692). Fix: after storing the seed in rawFulfillRandomWords run the same gas-bounded try this.finalizeReveals(batch) {} catch {} as src/BMCIlluminatiPass.sol lines 307-312, have the deployer finalize the rest in the fulfilment block via a bundle, and/or make tierOf/tokenURI derive the outcome from globalSeed for tokens whose draw is already determined.","line":313,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"State: 500 minted, requestSeed() done, coordinator delivers word 0xC0FFEE. Compute r = keccak256(abi.encode(globalSeed, 41)) % 460 off-chain; expected tier = r < 37 ? Genesis : Standard. Observe tierOf(41) == Pending, revealStatusOf(41) == AwaitingRandomness, and transferFrom(alice, 0xBEEF, 41) succeeds. Then finalizeReveals(1): tierOf(41) equals the precomputed tier. Expected: outcomes are reflected on-chain in the delivery transaction (at least a first batch) or not knowable before they are on-chain. Actual: all 460 tokens stay Pending and transferable until a third party pays ~39M gas.","severity":"low","snippet":"    function finalizeReveals(uint256 maxCount) external returns (uint256 done) {","title":"Every mystery outcome is publicly computable from globalSeed before finalizeReveals, while tokens still show Pending and trade; V3 dropped V1's inline callback batch"},{"citation":"unresolved","description":"Scope note: the pinned checkout (17aa386, main) does not contain src/experimental/* or the V3 tests; this finding refers to branch security/vrf-recovery at commit bf2a63c, reproduced in a separate worktree of that commit. If the deadline passes with totalMinted <= 40 (Genesis phase incomplete or nothing sold), the mystery phase never opened and there is nothing to reveal, yet any address can call requestSeed(): it closes the sale, passes the s_provingKeys check and sends a real requestRandomWords, charging the subscription the fulfilment cost (gas x 1.24 premium, up to ~0.17 ETH at the 500 gwei lane cap) for a word finalizeReveals() will never use (the loop body never runs because tokenId 41 > totalMinted). The one-shot request slot is consumed and the coordinator keeps a pending commitment that blocks cancelSubscription until fulfilled. Merged from audit_flow (cd0fac2c), audit_economics (e5bca14a) and audit_permissions (c3087eba). Fix: require(totalMinted > GENESIS_PHASE_SUPPLY, 'nothing to reveal') in requestSeed().","line":252,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Deploy with a 30-day deadline; mint 10 Genesis; warp to the deadline; anyone calls requestSeed(). Expected: revert, no request. Actual: coordinator.nextRequestId increments (request made and billed at fulfilment), saleClosed == true, seedRequestId set; after fulfilment finalizeReveals(100) returns 0 and pendingReveals() == 0.","severity":"low","snippet":"    function requestSeed() external returns (uint256 requestId) {\n        if (!saleClosed) closeSale();\n        require(!seedReceived, \"seed already received\");\n        require(seedRequestId == 0, \"seed already requested\");","title":"requestSeed() closes the sale and fires a paid VRF request even when no mystery token exists"},{"citation":"unresolved","description":"Scope note: the pinned checkout (17aa386, main) does not contain src/experimental/* or the V3 tests; this finding refers to branch security/vrf-recovery at commit bf2a63c, reproduced in a separate worktree of that commit. remainingSupply() (line 384), remainingMysteryMints() (line 393), remainingMysteryGenesis() (line 403) and remainingMysteryStandard() (line 407) check only totalMinted/revealedCount, while currentPrice() and genesisPhaseActive() also check saleClosed and saleDeadline. After the deadline or closeSale() these views report inventory that can never be minted and outcomes that can never be drawn. website/app.js reads remainingSupply, remainingMysteryGenesis and remainingMysteryStandard to display supply and remaining-inventory odds, so a closed sale shows live inventory to buyers and integrators. Merged from audit_math (2d6eca3b), audit_flow (68c7b089) and audit_permissions (28a7f101). Fix: return 0 from remainingSupply/remainingMysteryMints when saleClosed || block.timestamp >= saleDeadline, and either document that remainingMysteryGenesis/Standard count undrawn outcomes or return 0 once the sale is closed and all sold tokens are finalized.","line":393,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Mint 40 Genesis + 46 mystery (ids 41-86), warp to the deadline, requestSeed(), fulfil with word 42, finalizeReveals(100) -> 46 finalized, mysteryGenesisRevealed == 3. Expected: remainingSupply() == 0 and remainingMysteryMints() == 0 (sale is over), consistent with currentPrice() == 0 and mintMystery() reverting SoldOut. Actual: remainingSupply() == 414, remainingMysteryMints() == 414, remainingMysteryGenesis() == 34, remainingMysteryStandard() == 380 forever. Also: 39 Genesis minted and warp past the deadline -> remainingMysteryMints() == 460, remainingSupply() == 461 while currentPrice() == 0.","severity":"low","snippet":"        return totalMinted < GENESIS_PHASE_SUPPLY ? MYSTERY_SUPPLY : MAX_SUPPLY - totalMinted;","title":"Supply and inventory views ignore sale closure: unsold positions and undrawn outcomes are reported as available after the sale has ended"},{"citation":"unresolved","description":"Scope note: the pinned checkout (17aa386, main) does not contain src/experimental/* or the V3 tests; this finding refers to branch security/vrf-recovery at commit bf2a63c, reproduced in a separate worktree of that commit. _finalize always draws from remaining = 460 - revealedCount regardless of how many mystery tokens were sold. When the sale closes at the deadline with N < 460 mystery mints, the Genesis outcomes left in the 460 - N unsold slots are never minted, so the number of mystery Genesis that exist is hypergeometric(460, 37, N) rather than 37, and Standard likewise. Yet the permanent on-chain metadata states for every Genesis 'One of 77 Genesis pieces in a 500-piece collection' (line 459) and for every Standard 'One of 423 Standard pieces in a 500-piece collection' (line 485). Each individual draw stays fair at 37/460, so this concerns the supply and rarity claims, not the fairness of the draw. From audit_math (75edb3bf). Fix: either keep the design and make the metadata and views supply-dependent (e.g. 'Genesis supply = 40 + mysteryGenesisRevealed' once reveals finish), or, as an explicit scope decision, shrink the pool to the sold count with a guaranteed Genesis quota (this changes the economics).","line":459,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Mock VRF, seed 42: mint 40 Genesis, then 46 mystery tokens (ids 41-86). Warp to the deadline, requestSeed(), fulfil with word 42, finalizeReveals(100). Expected per the metadata and constants: 77 Genesis in a 500-piece collection. Actual: mysteryGenesisRevealed == 3, so 43 Genesis exist out of totalSupply() 86; tokenURI(2) still decodes to a description containing 'One of 77 Genesis pieces in a 500-piece collection' and a revealed Standard token's description contains 'One of 423 Standard pieces in a 500-piece collection'.","severity":"low","snippet":"                : \"A Genesis BMC Illuminati Pass. A mysterious cartoon frog in a black hooded cloak with premium gold-and-black styling, a glowing golden third eye and a floating golden eye-in-triangle. One of 77 Genesis pieces in a 500-piece collection minted by permanently burning BMC.\",","title":"Partial sell-through: the reveal draws from the full 460-slot pool, so the fixed metadata claim of 77 Genesis / 423 Standard is false whenever the sale closes short"},{"citation":"unresolved","description":"Scope note: the pinned checkout (17aa386, main) does not contain src/experimental/* or the V3 tests; this finding refers to branch security/vrf-recovery at commit bf2a63c, reproduced in a separate worktree of that commit. mintMystery() requires totalMinted >= 40 (line 232) and Phase 1 shares the single immutable saleDeadline with Phase 2. Nothing extends the deadline or opens Phase 2 early. If Genesis demand stops one short before the deadline, Phase 2 never opens: after the deadline mintGenesis reverts GenesisPhaseOver, mintMystery reverts GenesisPhaseNotOver, closeSale freezes the collection at 39 tokens, and Genesis artworks 40-77 plus all 423 Standard pieces are never minted. From audit_math (4508868c). Fix, preserving the two phases: give Phase 1 its own deadline after which Phase 2 opens (dropping or re-pooling unsold Genesis ids explicitly), or let mintMystery open once block.timestamp >= genesisPhaseDeadline.","line":232,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Deploy with deadline = now + 30 days. Mint 39 Genesis. mintMystery() reverts GenesisPhaseNotOver. Warp 30 days: mintGenesis() reverts GenesisPhaseOver and mintMystery() reverts GenesisPhaseNotOver; closeSale() succeeds with totalMinted == 39. Expected: the mystery phase becomes sellable or the 460 positions are clearly closed. Actual: zero mystery sales are possible while remainingMysteryMints() still returns 460.","severity":"low","snippet":"        if (totalMinted < GENESIS_PHASE_SUPPLY) revert GenesisPhaseNotOver();","title":"Mystery phase opens only when all 40 Genesis sell under the same immutable deadline: 39 Genesis sold by the deadline makes all 460 mystery positions permanently unsellable"},{"citation":"unresolved","description":"Scope note: the pinned checkout (17aa386, main) does not contain src/experimental/* or the V3 tests; this finding refers to branch security/vrf-recovery at commit bf2a63c, reproduced in a separate worktree of that commit. The constructor rejects only an empty string. tokenURI concatenates 'ipfs://' + genesisImagesCID + '/' + file, so a deployer who passes 'ipfs://<cid>' or '<cid>/' (both common copy-paste forms) locks 'ipfs://ipfs://<cid>//genesis-NN.png' into every Genesis token with no setter to repair it. The CID binds content so pinned bytes cannot be swapped, but availability still depends on off-chain pinning (trust assumption). From audit_permissions (cfc39c83). Fix: reject CIDs containing ':' or '/' and require a plausible prefix/length (bafy... or Qm...), and keep the dry-run assertion that the CID equals the pinned one.","line":197,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Deploy with initialGenesisCID = 'ipfs://bafybeihfihwwpev4gkqjuqawgtptsv3ynzsokclo6odsolvoa65aqaac2u/'; mintGenesis(); base64-decode tokenURI(1): the image field is 'ipfs://ipfs://bafybeihf...aac2u//genesis-01.png' (double scheme, double slash). Expected: constructor reverts. Actual: deployment succeeds and the broken URI is permanent.","severity":"low","snippet":"        if (bytes(initialGenesisCID).length == 0) revert EmptyCID();","title":"Genesis CID is frozen without format validation; a scheme-prefixed or slash-terminated CID yields permanently broken image URIs for all 77 Genesis tokens"},{"citation":"unresolved","description":"Scope note: the pinned checkout (17aa386, main) does not contain src/experimental/* or the V3 tests; this finding refers to branch security/vrf-recovery at commit bf2a63c, reproduced in a separate worktree of that commit. The deadline is validated only as strictly in the future. closeSale requires totalMinted == MAX_SUPPLY || block.timestamp >= saleDeadline, and requestSeed/finalizeReveals depend on it. A deadline such as type(uint64).max or a value mistakenly given in milliseconds means a sale that does not reach 500 can never close, and every mystery buyer's BMC is burned for a token that stays Pending forever; closingTime = now + 1 gives a sale already over. The dry-run script uses 30 days, but the contract has no guard and the owner has no setters. From audit_math (72b23f38). Fix: bound the deadline in the constructor, e.g. closingTime >= block.timestamp + 1 days && closingTime <= block.timestamp + 365 days.","line":191,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Deploy with closingTime = type(uint64).max. Mint 40 Genesis and 1 mystery, warp 3650 days. requestSeed() reverts 'not closed yet' and finalizeReveals(1) reverts 'seed pending'. Expected: the sale closes and token 41 can be revealed. Actual: token 41 stays Pending after 350,000 BMC was burned.","severity":"info","snippet":"        require(closingTime > block.timestamp, \"deadline must be future\");","title":"Constructor accepts any closingTime in the future: a far-future or mis-scaled deadline makes a partially sold collection impossible to close, so reveals never start"},{"citation":"unresolved","description":"Scope note: the pinned checkout (17aa386, main) does not contain src/experimental/* or the V3 tests; this finding refers to branch security/vrf-recovery at commit bf2a63c, reproduced in a separate worktree of that commit. V3 moved to a single post-sale request but kept the per-token scaffolding: requestIdOf, tokenIdOfRequest and _randomWord are never written (public getters always return 0; delete _randomWord[tokenId] in _finalize is a no-op SSTORE on every reveal); RevealStatus.RandomnessStored, event RandomnessStored, event VRFConfigSet, errors GenesisCIDAlreadySet and VRFNotConfigured (configured is always true) and constants CALLBACK_REVEAL_BATCH, GAS_PER_REVEAL, CALLBACK_GAS_RESERVE are unused. MysteryMinted always emits requestId = 0 so indexers keyed on it break, and revealStatusOf reports AwaitingRandomness for tokens minted before any request exists. Ownable is inherited but no function is onlyOwner, so the owner can only transfer/renounce a role with no effect; the README 'Privileged powers' table (setGenesisImagesCID, setVRFConfig) and its claim that VRF keeps unfunded requests pending describe V1, not V3. The real privileged party is the VRF subscription owner (see the high finding on validation). Merged from audit_flow (96d291c1), audit_economics (ec44146c, 1b37b5b6). Fix: remove or document the dead members, drop the requestId field from MysteryMinted, add a status value for 'sale open, no request yet', and state in the trust model that the owner has no powers and that the subscription owner is trusted.","line":237,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Mint 40 Genesis then mintMystery(): the MysteryMinted event carries requestId 0; requestIdOf(41) == 0 and tokenIdOfRequest(x) == 0 for the life of the contract; revealStatusOf(41) == AwaitingRandomness while seedRequestId() == 0. grep onlyOwner src/experimental/BMCIlluminatiPassCandidateV3.sol returns nothing.","severity":"info","snippet":"        emit MysteryMinted(msg.sender, tokenId, 0, MYSTERY_PRICE);","title":"Leftover per-mint VRF scaffolding, misleading events and an owner role with no powers; README and website document the V1 API"},{"citation":"unresolved","description":"Scope note: the pinned checkout (17aa386, main) does not contain src/experimental/* or the V3 tests; this finding refers to branch security/vrf-recovery at commit bf2a63c, reproduced in a separate worktree of that commit. Independent run (forge 1.8.3, solc 0.8.26): on branch security/vrf-recovery plain forge test reports 81 tests, 80 passed, 1 failed; the failure is test/BMCMainnetFork.t.sol which requires chainid 1 and passes with --fork-url (verified against mainnet). On the pinned checkout 17aa386 the figure is 44 tests, 43 passed, 1 failed (same fork test). The '80 passed, 0 failed' claim therefore matches CI's --no-match-contract BMCMainnetForkTest run, not a plain run. Only 9 tests target V3 and none covers the paths where the design fails: test_OnlyOneSeedRequestEvenAfterSevenDays (line 82) asserts that a second request after 8 days reverts and then fulfils the first, i.e. the stall is tested as intended; test_UnknownGasLaneCannotRequestSeed treats a permanent revert as correct. Untested: an expired/never-fulfilled request, a sale closed by deadline with fewer than 460 mystery tokens followed by reveal, requestSeed with zero mystery tokens, views after early close, callback gas, and V3 on a mainnet fork or against Chainlink's own VRFCoordinatorV2_5Mock, which has no s_provingKeys getter (grep count 0), so V3.requestSeed() reverts 'unregistered gas lane' against it; all official-mock tests target src/BMCIlluminatiPass.sol. docs/TEST-RESULTS.md records the 43 V1 tests only. Merged from audit_flow (9a9a6478), audit_economics (151d1201) and audit_permissions (e1775253). Verdict for the requester: V3 is not mainnet-ready while the two high V3 findings stand; the file header itself says NOT FOR DEPLOYMENT.","line":82,"path":"test/BMCIlluminatiPassCandidateV3.t.sol","reproduction":"On branch security/vrf-recovery run forge test without an RPC: 'Ran 12 test suites: 80 tests passed, 1 failed (81 total)'; forge test --match-contract BMCMainnetForkTest --fork-url <mainnet> passes. On 17aa386: '43 tests passed, 1 failed (44 total)'. forge test --match-path test/BMCIlluminatiPassCandidateV3.t.sol lists 9 tests; none warps past an unfulfilled request and asserts recovery, none mints fewer than 460 mystery tokens before reveal, none deploys V3 on a fork.","severity":"info","snippet":"    function test_OnlyOneSeedRequestEvenAfterSevenDays() public {","title":"Test results verified: 80 passed is the CI figure excluding the fork test; V3 tests assert the stall and the unregistered lane as expected behaviour and never run against Chainlink's official mock or "}],"hash":"220bdc28adc69b626868313e90323c8fe56a3789d142c30220fe8023857d6083","nodeId":"86b4f39c-ab79-423f-a793-3044b179579b","outcome":"completed","summary":"The review record is complete in `.imd-findings.json` with 14 findings, and no repository file was changed.\n\n**What was wrong with the two flagged findings.** Both cited `src/experimental/BMCIlluminatiPassCandidateV3.sol`, which exists only on branch `security/vrf-recovery` at bf2a63c. The pinned tree is main at 17aa386 and has no `src/experimental/`, so neither finding nor its proof could be checked there.\n\n**How I resolved them.**\n- The single-request stall finding (V3, line 255) stays as a branch-only note. The line and snippet were re-verified against the branch blob, and its proof was re-run on a worktree of bf2a63c, where it fails with \"seed already requested\". The description now says explicitly that the proof cannot compile on the pinned tree and points to the in-tree counterpart at `src/BMCIlluminatiPass.sol:321`.\n- The unvalidated key hash and subscription finding was re-anchored to the production contract, where the same defect exists. It now cites `src/BMCIlluminatiPass.sol:204`, the range check in setVRFConfig that never consults the coordinator. Severity is medium, since the trigger is a privileged misconfiguration. The V3 constructor variant and the mainnet-fork evidence stay in the description. A new in-tree proof replaces the branch-only one. It fails on current code because setVRFConfig accepts an unregistered lane and the next mystery mint burns 350,000 BMC into a request no node will fulfil. With a two-line `s_provingKeys` check added in a throwaway worktree, the same test passes.\n\n**Checks run.**\n- The in-order stall proof at `src/BMCIlluminatiPass.sol:321` still fails in the tree with \"token 42 stuck behind token 41\".\n- All 14 snippets now match their cited line, either in the tree or in the branch blob. One V3 finding was off by one line and was moved from 253 to 252.\n- Three findings carry proofs, within the limit of four. Temporary worktrees were removed.\n\n**What remains outside the tree.** Eleven findings still cite branch-only files, so they are kept as notes and cannot open a revision against main. That is a property of the pinned checkout, not of the findings, and each description states the branch and commit it was reproduced on.","treeHash":null,"usage":{"cachedInputTokens":2696722,"inputTokens":932,"model":"claude-fable-5-1","outputTokens":59452,"runtime":"claude","turns":48,"wallClockMs":904108}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"4ebee02fc506f27c","findings":[{"citation":"unresolved","description":"Scope note: the pinned checkout (HEAD 17aa386, main) does not contain src/experimental/* or the branch's test files; the V3 source, its test file, the dry-run script and docs were fetched from branch security/vrf-recovery (bf2a63c) over HTTPS and line numbers refer to that file. requestSeed() may be called exactly once: once seedRequestId is set it reverts with \"seed already requested\" for ever, and rawFulfillRandomWords() only accepts that one id. Chainlink VRF v2.5 does not keep requests alive indefinitely: the official subscription docs state that when the subscription balance is below the minimum, \"requests remain pending for up to 24 hours before they expire\". The dry-run script deploys with nativePayment=true, so the subscription must hold enough ETH (not LINK) at fulfilment time for a 500,000-gas callback at the 500 gwei lane's price; if it does not for 24 hours after requestSeed(), the node drops the request and nothing in the contract can ever open another one. Because every mystery token (up to 460, 161,000,000 BMC burned) depends on this single word, all of them stay Tier.Pending permanently; the owner has no setter, the subscription owner cannot help, and anyone can trigger the request at the deadline (requestSeed is permissionless) regardless of subscription state. The contract's own header concedes this (\"A permanently unfulfilled request may permanently prevent reveals\") and the branch test test_OnlyOneSeedRequestEvenAfterSevenDays enshrines the stall as expected behaviour. Fix (preserves the first-valid-word-is-permanent property): record seedRequestedAt; allow requestSeed() again when !seedReceived and block.timestamp >= seedRequestedAt + TIMEOUT (e.g. 7 days); track every issued id in a mapping so rawFulfillRandomWords accepts a late delivery of any of them and the first delivered word wins. Additionally require the subscription's native/LINK balance to be non-zero in requestSeed() so a permissionless caller cannot fire the request into an unfunded subscription.","line":255,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {ERC20Burnable} from \"@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol\";\nimport {VRFV2PlusClient} from \"@chainlink/contracts/src/v0.8/vrf/dev/libraries/VRFV2PlusClient.sol\";\nimport {BMCIlluminatiPassCandidateV3} from \"src/experimental/BMCIlluminatiPassCandidateV3.sol\";\n\n/// @dev Self-contained BMC stand-in: 18 decimals, OpenZeppelin burnFrom semantics.\ncontract ProofBMC is ERC20, ERC20Burnable {\n    constructor() ERC20(\"BMC Illuminati\", \"BMC\") {}\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\n/// @dev Minimal VRF v2.5 coordinator stand-in with a registered proving key and request expiry.\n///      `expire` models the Chainlink node dropping a request that stayed unpaid for 24 hours\n///      (docs.chain.link, VRF v2.5 subscription overview: \"requests remain pending for up to\n///      24 hours before they expire\"). An expired request is never delivered.\ncontract ProofCoordinator {\n    bytes32 public constant VALID_KEY = keccak256(\"registered-key\");\n\n    struct Req {\n        address consumer;\n        uint32 callbackGasLimit;\n        bool fulfilled;\n        bool expired;\n    }\n\n    uint256 public nextRequestId = 1000;\n    mapping(uint256 => Req) public requests;\n\n    function s_provingKeys(bytes32 key) external pure returns (bool exists, uint64 maxGas) {\n        return (key == VALID_KEY, key == VALID_KEY ? uint64(500 gwei) : uint64(0));\n    }\n\n\n    function getSubscription(uint256)\n        external\n        view\n        returns (uint96 balance, uint96 nativeBalance, uint64 reqCount, address owner, address[] memory consumers)\n    {\n        return (0, 1 ether, 0, address(this), new address[](0));\n    }\n\n    function requestRandomWords(VRFV2PlusClient.RandomWordsRequest calldata req) external returns (uint256 id) {\n        id = nextRequestId++;\n        requests[id] = Req({consumer: msg.sender, callbackGasLimit: req.callbackGasLimit, fulfilled: false, expired: false});\n    }\n\n    function expire(uint256 requestId) external {\n        requests[requestId].expired = true;\n    }\n\n    function fulfill(uint256 requestId, uint256 word) external returns (bool ok) {\n        Req storage r = requests[requestId];\n        require(r.consumer != address(0), \"unknown request\");\n        require(!r.fulfilled && !r.expired, \"cannot fulfil\");\n        r.fulfilled = true;\n        uint256[] memory words = new uint256[](1);\n        words[0] = word;\n        (ok,) = r.consumer.call{gas: r.callbackGasLimit}(\n            abi.encodeWithSignature(\"rawFulfillRandomWords(uint256,uint256[])\", requestId, words)\n        );\n    }\n}\n\n/// @title Proof: the sole seed request has no recovery path once the Chainlink node drops it.\n/// @notice Fails on the current code: after the only request expires unfulfilled, every one of the\n///         sold mystery tokens stays pending forever and `requestSeed()` reverts with\n///         \"seed already requested\". Passes once the contract allows a fresh request for an\n///         unfulfilled seed after a liveness timeout (first delivered word still permanent).\ncontract V3SeedRequestStallProof is Test {\n    uint256 internal constant REREQUEST_TIMEOUT = 7 days;\n\n    ProofBMC bmc;\n    ProofCoordinator coord;\n    BMCIlluminatiPassCandidateV3 pass;\n    address alice = makeAddr(\"alice\");\n    uint64 deadline;\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        deadline = uint64(block.timestamp + 30 days);\n        bmc = new ProofBMC();\n        coord = new ProofCoordinator();\n        pass = new BMCIlluminatiPassCandidateV3(\n            address(this), address(bmc), address(coord), deadline, 123, coord.VALID_KEY(), 500_000, 3, true, \"bafyTestCID\"\n        );\n        bmc.mint(alice, 201_000_000 ether);\n        vm.startPrank(alice);\n        bmc.approve(address(pass), type(uint256).max);\n        for (uint256 i; i < 40; ++i) pass.mintGenesis();\n        for (uint256 i; i < 5; ++i) pass.mintMystery();\n        vm.stopPrank();\n    }\n\n    function test_ExpiredSoleSeedRequestCanBeReplaced() public {\n        vm.warp(deadline);\n        uint256 first = pass.requestSeed();\n        assertEq(pass.seedRequestId(), first);\n\n        // The subscription was not funded in time; the node drops the request after 24h.\n        coord.expire(first);\n        vm.warp(block.timestamp + REREQUEST_TIMEOUT + 1);\n        assertFalse(pass.seedReceived());\n        assertEq(pass.revealedCount(), 0);\n\n        // Expected: a liveness path exists and a second request can be opened.\n        // Actual (current code): revert \"seed already requested\"; tokens 41..45 are pending forever.\n        uint256 second = pass.requestSeed();\n        assertTrue(second != 0 && second != first, \"a fresh request must be opened\");\n\n        // The replacement request is delivered and finalization proceeds.\n        assertTrue(coord.fulfill(second, 777));\n        assertTrue(pass.seedReceived());\n        pass.finalizeReveals(10);\n        assertEq(pass.revealedCount(), 5);\n        assertEq(uint256(pass.revealStatusOf(41)), uint256(BMCIlluminatiPassCandidateV3.RevealStatus.Revealed));\n    }\n}","reproduction":"State: 40 Genesis + 5 mystery minted, deadline reached. 1) anyone calls requestSeed() -> seedRequestId=1000. 2) subscription is unfunded; Chainlink node never fulfils and expires the request after 24h. 3) 7 days later call requestSeed(). Expected: a new request can be opened and reveals proceed. Actual: revert \"seed already requested\"; revealStatusOf(41..45) stays AwaitingRandomness, tierOf stays Pending, forever. Run: forge test --match-path test/scratch/V3SeedRequestStall.t.sol (fails on current code).","severity":"high","snippet":"        require(seedRequestId == 0, \"seed already requested\");","title":"V3: sole VRF seed request has no recovery path; an expired request leaves every mystery token pending forever"},{"citation":"unresolved","description":"Scope note: the pinned checkout (HEAD 17aa386, main) does not contain src/experimental/* or the branch's test files; the V3 source, its test file, the dry-run script and docs were fetched from branch security/vrf-recovery (bf2a63c) over HTTPS and line numbers refer to that file. The constructor validates only ranges (subscriptionId != 0, keyHash != 0, gas and confirmation bounds). keyHash and subscriptionId are then frozen with no setter. The first contact with the coordinator is in requestSeed(), which runs after closeSale(), i.e. after up to 201,000,000 BMC has been irreversibly burned. If keyHash is not a registered proving key the staticcall check reverts \"unregistered gas lane\" on every call; if subscriptionId does not exist (typo, or the subscription owner cancelled it before the request, which the coordinator allows while no request is pending) requestRandomWords reverts InvalidSubscription() on every call. Either way seedRequestId stays 0, no randomness can ever be requested, and all mystery tokens remain Pending permanently. The branch test test_UnknownGasLaneCannotRequestSeed deploys with an unregistered key and treats the permanent revert as correct. The main-branch contract avoided this class by letting the owner re-point setVRFConfig; V3 removed that without adding a deploy-time check. Fix: in the constructor, staticcall s_provingKeys(keyHash) on the coordinator and require exists, and call VRF_COORDINATOR.getSubscription(subscriptionId) (reverts InvalidSubscription when absent); optionally also require that this contract is in the returned consumers list or that the deployer adds it in the same transaction. Keep the immutability; just fail at deployment.","line":204,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {VRFV2PlusClient} from \"@chainlink/contracts/src/v0.8/vrf/dev/libraries/VRFV2PlusClient.sol\";\nimport {BMCIlluminatiPassCandidateV3} from \"src/experimental/BMCIlluminatiPassCandidateV3.sol\";\n\n/// @dev Coordinator stand-in exposing the two read paths the real VRFCoordinatorV2_5 has:\n///      `s_provingKeys(bytes32)` (public mapping getter) and `getSubscription(uint256)` which\n///      reverts with InvalidSubscription() for an unknown or cancelled subscription id.\ncontract ValidationCoordinator {\n    error InvalidSubscription();\n\n    bytes32 public constant VALID_KEY = keccak256(\"registered-key\");\n    uint256 public constant VALID_SUB = 123;\n\n    function s_provingKeys(bytes32 key) external pure returns (bool exists, uint64 maxGas) {\n        return (key == VALID_KEY, key == VALID_KEY ? uint64(500 gwei) : uint64(0));\n    }\n\n    function getSubscription(uint256 subId)\n        external\n        view\n        returns (uint96 balance, uint96 nativeBalance, uint64 reqCount, address owner, address[] memory consumers)\n    {\n        if (subId != VALID_SUB) revert InvalidSubscription();\n        return (0, 1 ether, 0, address(this), new address[](0));\n    }\n\n    function requestRandomWords(VRFV2PlusClient.RandomWordsRequest calldata req) external pure returns (uint256) {\n        if (req.subId != VALID_SUB) revert InvalidSubscription();\n        return 1000;\n    }\n}\n\n/// @title Proof: constructor accepts VRF parameters that can never produce randomness.\n/// @notice The key hash and subscription id are immutable and only exercised by `requestSeed()`,\n///         i.e. after the sale has closed and up to 201,000,000 BMC has been burned. A wrong value\n///         therefore turns into a permanent reveal stall instead of a failed deployment. Fails on\n///         the current code (the constructor succeeds); passes once the constructor validates the\n///         gas lane via `s_provingKeys` and the subscription via `getSubscription` on the\n///         immutable coordinator and reverts otherwise.\ncontract V3ConstructorValidationProof is Test {\n    ValidationCoordinator coord;\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        coord = new ValidationCoordinator();\n    }\n\n    function _deploy(uint256 subId, bytes32 key) internal returns (BMCIlluminatiPassCandidateV3) {\n        return new BMCIlluminatiPassCandidateV3(\n            address(this), address(0xB), address(coord), uint64(block.timestamp + 30 days), subId, key, 500_000, 3, true, \"bafyTestCID\"\n        );\n    }\n\n    function test_ValidParametersStillDeploy() public {\n        BMCIlluminatiPassCandidateV3 ok = _deploy(coord.VALID_SUB(), coord.VALID_KEY());\n        assertTrue(ok.genesisCIDLocked());\n    }\n\n    function test_UnregisteredKeyHashMustRevertAtDeployment() public {\n        // Expected: deployment reverts. Actual: deploys; requestSeed() will revert\n        // \"unregistered gas lane\" forever once the sale is over.\n        uint256 validSub = coord.VALID_SUB();\n        vm.expectRevert();\n        _deploy(validSub, keccak256(\"typo-key-hash\"));\n    }\n\n    function test_UnknownSubscriptionMustRevertAtDeployment() public {\n        // Expected: deployment reverts. Actual: deploys; requestSeed() will revert with\n        // InvalidSubscription() forever because subscriptionId cannot be changed.\n        bytes32 validKey = coord.VALID_KEY();\n        vm.expectRevert();\n        _deploy(999, validKey);\n    }\n}","reproduction":"Deploy with keyHash=keccak256(\"typo-key-hash\") (or subscriptionId=999 that the coordinator does not know). Expected: deployment reverts. Actual: deployment succeeds; after the sale, requestSeed() reverts \"unregistered gas lane\" (resp. InvalidSubscription()) on every call and no mystery token can ever be revealed. Run: forge test --match-path test/scratch/V3ConstructorValidation.t.sol (two tests fail on current code).","severity":"high","snippet":"        vrfConfig = VRFConfig(subscriptionId, keyHash, callbackGasLimit, requestConfirmations, nativePayment, true);","title":"V3: constructor never checks the immutable gas lane / subscription against the coordinator; a wrong value surfaces only after the sale and is unfixable"},{"citation":"unresolved","description":"Scope note: the pinned checkout (HEAD 17aa386, main) does not contain src/experimental/* or the branch's test files; the V3 source, its test file, the dry-run script and docs were fetched from branch security/vrf-recovery (bf2a63c) over HTTPS and line numbers refer to that file. Information asymmetry. rawFulfillRandomWords stores globalSeed and returns. From that transaction on, the tier of every pending token is a pure function of public state (keccak(globalSeed, tokenId) over the deterministic Fisher-Yates), but tierOf()/tokenURI() keep reporting Pending until someone pays to call finalizeReveals(). A searcher who watches the VRF node's fulfilment transaction (or backruns it in the same block) can run finalizeReveals via eth_call, learn exactly which token ids are the 37 Genesis, and buy them from holders or marketplace listings priced as mysteries before the first finalizeReveals lands. The pending metadata on this branch even warns \"rarity can be calculated before this metadata is finalized\", i.e. the asymmetry is known but left in place. The main-branch contract mitigates this by finalizing a gas-aware in-order batch inside the callback; V3 kept the constants (CALLBACK_REVEAL_BATCH, GAS_PER_REVEAL, CALLBACK_GAS_RESERVE) but no longer uses them. Fix: after storing the seed, run the same gas-bounded try this.finalizeReveals(batch) {} catch {} as src/BMCIlluminatiPass.sol lines 307-312, size callbackGasLimit so the batch is meaningful, and document that holders should not trade between SeedReceived and full finalization (or have the deployer call finalizeReveals(460) in the fulfilment block via a bundle).","line":302,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {ERC20Burnable} from \"@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol\";\nimport {VRFV2PlusClient} from \"@chainlink/contracts/src/v0.8/vrf/dev/libraries/VRFV2PlusClient.sol\";\nimport {BMCIlluminatiPassCandidateV3} from \"src/experimental/BMCIlluminatiPassCandidateV3.sol\";\n\ncontract ProofBMC is ERC20, ERC20Burnable {\n    constructor() ERC20(\"BMC Illuminati\", \"BMC\") {}\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\ncontract ProofCoordinator {\n    bytes32 public constant VALID_KEY = keccak256(\"registered-key\");\n\n    struct Req {\n        address consumer;\n        uint32 callbackGasLimit;\n        bool fulfilled;\n    }\n\n    uint256 public nextRequestId = 1000;\n    mapping(uint256 => Req) public requests;\n\n    function s_provingKeys(bytes32 key) external pure returns (bool exists, uint64 maxGas) {\n        return (key == VALID_KEY, key == VALID_KEY ? uint64(500 gwei) : uint64(0));\n    }\n\n\n    function getSubscription(uint256)\n        external\n        view\n        returns (uint96 balance, uint96 nativeBalance, uint64 reqCount, address owner, address[] memory consumers)\n    {\n        return (0, 1 ether, 0, address(this), new address[](0));\n    }\n\n    function requestRandomWords(VRFV2PlusClient.RandomWordsRequest calldata req) external returns (uint256 id) {\n        id = nextRequestId++;\n        requests[id] = Req({consumer: msg.sender, callbackGasLimit: req.callbackGasLimit, fulfilled: false});\n    }\n\n    /// @dev Deliver with exactly the request's callbackGasLimit, like the real coordinator.\n    function fulfill(uint256 requestId, uint256 word) external returns (bool ok) {\n        Req storage r = requests[requestId];\n        require(r.consumer != address(0) && !r.fulfilled, \"cannot fulfil\");\n        r.fulfilled = true;\n        uint256[] memory words = new uint256[](1);\n        words[0] = word;\n        (ok,) = r.consumer.call{gas: r.callbackGasLimit}(\n            abi.encodeWithSignature(\"rawFulfillRandomWords(uint256,uint256[])\", requestId, words)\n        );\n    }\n}\n\n/// @title Proof: seed delivery reveals nothing on-chain while making every outcome computable.\n/// @notice After the coordinator delivers the seed, all 460 outcomes are fixed and anyone can\n///         compute them with an eth_call (here: snapshot + finalizeReveals + revert), yet every\n///         token still reports Tier.Pending and trades as a mystery until someone pays to call\n///         finalizeReveals. Fails on the current code because the callback stores the seed and\n///         returns; passes once the callback finalizes a gas-bounded in-order batch atomically\n///         (the pattern src/BMCIlluminatiPass.sol already uses), closing the window for the\n///         first tokens and shrinking it for the rest.\ncontract V3RevealAsymmetryProof is Test {\n    ProofBMC bmc;\n    ProofCoordinator coord;\n    BMCIlluminatiPassCandidateV3 pass;\n    address alice = makeAddr(\"alice\");\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        bmc = new ProofBMC();\n        coord = new ProofCoordinator();\n        pass = new BMCIlluminatiPassCandidateV3(\n            address(this), address(bmc), address(coord), uint64(block.timestamp + 30 days), 123, coord.VALID_KEY(), 500_000, 3, true, \"bafyTestCID\"\n        );\n        bmc.mint(alice, 201_000_000 ether);\n        vm.startPrank(alice);\n        bmc.approve(address(pass), type(uint256).max);\n        for (uint256 i; i < 40; ++i) pass.mintGenesis();\n        for (uint256 i; i < 460; ++i) pass.mintMystery();\n        vm.stopPrank();\n    }\n\n    function test_SeedDeliveryFinalizesFirstBatchAtomically() public {\n        uint256 req = pass.requestSeed();\n        assertTrue(coord.fulfill(req, 0xC0FFEE));\n        assertTrue(pass.seedReceived());\n\n        // An observer computes the full outcome table with a dry run and discards the state.\n        uint256 snap = vm.snapshotState();\n        pass.finalizeReveals(460);\n        uint256 firstGenesis;\n        for (uint256 id = 41; id <= 500; ++id) {\n            if (pass.tierOf(id) == BMCIlluminatiPassCandidateV3.Tier.Genesis) {\n                firstGenesis = id;\n                break;\n            }\n        }\n        vm.revertToState(snap);\n\n        // On-chain the winning token is still a \"mystery\" that can be bought from its holder.\n        assertTrue(firstGenesis != 0);\n        assertEq(uint256(pass.tierOf(firstGenesis)), uint256(BMCIlluminatiPassCandidateV3.Tier.Pending));\n\n        // Expected after fix: the coordinator's delivery transaction itself finalized token 41.\n        // Actual: nothing was finalized inside the callback (500,000 gas was available).\n        assertEq(\n            uint256(pass.revealStatusOf(41)),\n            uint256(BMCIlluminatiPassCandidateV3.RevealStatus.Revealed),\n            \"callback must finalize an in-order batch\"\n        );\n    }\n}","reproduction":"State: 500 minted, requestSeed() done. Coordinator delivers word 0xC0FFEE with 500,000 gas. Then: snapshot; finalizeReveals(460); note the first id whose tier is Genesis; revert snapshot. Expected: at least the first in-order tokens are Revealed in the delivery transaction. Actual: revealStatusOf(41) is still AwaitingRandomness and tierOf(<known Genesis id>) is Pending while its outcome is already fixed and computable. Run: forge test --match-path test/scratch/V3RevealAsymmetry.t.sol (fails on current code).","severity":"medium","snippet":"    function rawFulfillRandomWords(uint256 requestId, uint256[] calldata randomWords) external {","title":"V3: seed delivery finalizes nothing, so every outcome is publicly computable while all 460 tokens still trade as unrevealed"},{"citation":"unresolved","description":"Scope note: the pinned checkout (HEAD 17aa386, main) does not contain src/experimental/* or the branch's test files; the V3 source, its test file, the dry-run script and docs were fetched from branch security/vrf-recovery (bf2a63c) over HTTPS and line numbers refer to that file. The contract owner has no power over randomness, but the owner of Chainlink subscription 2785245948...2985 (dry-run script) has several unilateral ways to stall the collection permanently, none of which the contract can detect or recover from, and this actor is not named in the trust model beyond \"administered on the Chainlink coordinator\": (a) never calling addConsumer(subId, pass) -> requestRandomWords reverts InvalidConsumer on every requestSeed(); (b) removeConsumer or cancelSubscription at any time before requestSeed() (the coordinator only blocks these while a request is pending) -> InvalidConsumer / InvalidSubscription forever because subscriptionId is immutable; (c) leaving the native balance empty for 24 hours after requestSeed() -> finding 1. In the main branch the contract owner could re-point setVRFConfig to a new subscription; V3 removed that, so the subscription owner's key is now a single point of failure for 161,000,000 BMC worth of mystery mints. Fix options that keep the no-owner-influence design: let the contract create and own its own subscription in the constructor (createSubscription + addConsumer(address(this)); anyone can fund it with fundSubscriptionWithNative), or allow a bounded owner setter for subscriptionId only while seedRequestId == 0 (cannot touch randomness), plus the deploy-time checks of finding 2.","line":266,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Deploy V3 with subscriptionId S owned by EOA X. Before the deadline X calls cancelSubscription(S, X) (allowed: no pending request). After the deadline anyone calls requestSeed(). Expected: the collection can still obtain randomness. Actual: VRF_COORDINATOR.requestRandomWords reverts InvalidSubscription() on every call; S can never be recreated; all mystery tokens stay Pending. Same outcome if X simply never calls addConsumer(S, pass) (InvalidConsumer).","severity":"medium","snippet":"                subId: cfg.subscriptionId,","title":"V3: trust gap - the external VRF subscription owner, not the contract owner, controls whether reveals can ever happen"},{"citation":"resolved","description":"This is the production contract in the pinned checkout (and the fallback if V3 is not deployed). Each mystery mint opens its own VRF request; finalizeReveals stops at the first token whose word is not stored. Lines 38-39 state that requests \"remain pending while the subscription is unfunded and are fulfilled once it is funded again\"; Chainlink's VRF v2.5 docs say instead that such requests \"remain pending for up to 24 hours before they expire\". So if the subscription runs dry for a day during the sale (the README notes the operator must keep it funded across 460 requests), every token minted in that window is permanently unfulfillable, and because of the in-order rule every token minted after it can never be revealed either, even though their words arrive. The owner's setVRFConfig only affects future mints and cannot rescue a stuck token. Related undisclosed owner power: setVRFConfig accepts any keyHash; the coordinator does not validate the key hash at request time, so a wrong lane (typo or malice) makes every subsequent mint's request unfulfillable and, through the same in-order rule, freezes all later reveals. Fix: add a permissionless timeout re-request per token (if a token is AwaitingRandomness for > N days anyone may call rerequestRandomness(tokenId), the new id maps to the token, first delivered word wins), and validate keyHash via s_provingKeys in setVRFConfig. Correct the NatSpec.","line":321,"path":"src/BMCIlluminatiPass.sol","reproduction":"Owner sets config, 40 Genesis minted. Alice mints 41, 42, 43 (requests r41, r42, r43). Subscription is empty for >24h; r41 expires; later r42 and r43 are delivered (words stored). finalizeReveals(10) returns 0 and revealStatusOf(42)==RandomnessStored forever; setVRFConfig(newSub,...) changes nothing for 41-43. Expected: tokens 42 and 43, whose randomness exists, eventually reveal; Actual: never. Second input: setVRFConfig(123, keccak256(\"typo\"), 500000, 3, true) then mintMystery() -> request accepted, token 41 can never be fulfilled, all later tokens blocked. Demonstrated by test/scratch/Demos.t.sol::test_Main_InOrderFinalizationBlockedByOneExpiredRequest and test_Main_UnregisteredKeyHashAcceptedByMint.","severity":"medium","snippet":"        while (done < maxCount && tokenId <= totalMinted && _revealStatus[tokenId] == RevealStatus.RandomnessStored) {","title":"Main contract: strict in-order finalization lets one expired per-token VRF request block every later token; NatSpec claims requests never expire"},{"citation":"unresolved","description":"Scope note: the pinned checkout (HEAD 17aa386, main) does not contain src/experimental/* or the branch's test files; the V3 source, its test file, the dry-run script and docs were fetched from branch security/vrf-recovery (bf2a63c) over HTTPS and line numbers refer to that file. If the deadline passes while fewer than 41 tokens are minted (Genesis phase incomplete, mystery phase never opened), closeSale() succeeds and requestSeed() is still callable by anyone. It closes the sale, performs the s_provingKeys check and sends a real requestRandomWords, charging the subscription for a word that finalizeReveals() can never use (loop body never runs because tokenId=41 > totalMinted). Fix: require(totalMinted > GENESIS_PHASE_SUPPLY, \"nothing to reveal\") in requestSeed().","line":253,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Deploy with a 30-day deadline; 10 Genesis minted; warp 30 days; call requestSeed(). Expected: revert, no request. Actual: coordinator nextRequestId increments (request billed), fulfilment stores a seed, finalizeReveals(100) returns 0. test/scratch/Demos.t.sol::test_V3_SeedRequestedWithNoMysteryTokens.","severity":"low","snippet":"        if (!saleClosed) closeSale();","title":"V3: requestSeed() fires a paid VRF request even when no mystery token exists"},{"citation":"unresolved","description":"Scope note: the pinned checkout (HEAD 17aa386, main) does not contain src/experimental/* or the branch's test files; the V3 source, its test file, the dry-run script and docs were fetched from branch security/vrf-recovery (bf2a63c) over HTTPS and line numbers refer to that file. After closeSale() (deadline reached with unsold inventory) nothing can be minted, but remainingMysteryMints() and remainingSupply() keep returning MAX_SUPPLY - totalMinted (and 460 if the Genesis phase never finished). The website/config reads these views; buyers see positions that do not exist. Fix: return 0 when saleClosed || block.timestamp >= saleDeadline, mirroring currentPrice().","line":393,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"40 Genesis + 3 mystery minted; warp past deadline; closeSale(). remainingMysteryMints() == 457 and remainingSupply() == 457 while currentPrice() == 0 and mintMystery() reverts SoldOut. test/scratch/Demos.t.sol::test_V3_ViewsAfterClose.","severity":"low","snippet":"        return totalMinted < GENESIS_PHASE_SUPPLY ? MYSTERY_SUPPLY : MAX_SUPPLY - totalMinted;","title":"V3: remainingMysteryMints()/remainingSupply() report unsold positions as mintable after the sale is closed"},{"citation":"unresolved","description":"Scope note: the pinned checkout (HEAD 17aa386, main) does not contain src/experimental/* or the branch's test files; the V3 source, its test file, the dry-run script and docs were fetched from branch security/vrf-recovery (bf2a63c) over HTTPS and line numbers refer to that file. The constructor only rejects an empty string. tokenURI concatenates \"ipfs://\" + genesisImagesCID + \"/\" + file, so a deployer who passes \"ipfs://<cid>\" or \"<cid>/\" (both common copy-paste forms) locks \"ipfs://ipfs://<cid>//genesis-NN.png\" into all 77 Genesis tokens with no setter to repair it. Integrity note: the CID binds the content, so pinned bytes cannot be swapped, but availability still depends on off-chain pinning (trust assumption; no on-chain remedy if the directory is unpinned). Fix: reject CIDs containing ':' or '/' and require a plausible length/prefix (bafy... or Qm...), and keep the dry-run assertion that the CID equals the pinned one.","line":198,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Deploy with initialGenesisCID=\"ipfs://bafybeihfihwwpev4gkqjuqawgtptsv3ynzsokclo6odsolvoa65aqaac2u/\"; mintGenesis(); base64-decode tokenURI(1): image field is \"ipfs://ipfs://bafybeihf.../\\/genesis-01.png\" (double scheme, double slash). Expected: constructor reverts. test/scratch/Demos.t.sol::test_V3_PrefixedCIDProducesBrokenURI logs the URI.","severity":"low","snippet":"        genesisImagesCID = initialGenesisCID;","title":"V3: Genesis CID is frozen without format validation; a prefixed or slash-terminated CID yields permanently broken image URIs"},{"citation":"resolved","description":"Independent run on HEAD 17aa386 with forge 1.8.3 / solc 0.8.26: `forge test --no-match-contract BMCMainnetForkTest` -> 43 passed, 0 failed (39 in BMCIlluminatiPass.t.sol, 4 in ChainlinkMockIntegration.t.sol), matching docs/TEST-RESULTS.md. The 80 figure corresponds to branch security/vrf-recovery, which adds nine test files (Candidate, V2, V3, VRFRecovery*, VRFSafetyProperties, BMCPassVRF*) that are not present in this checkout (partial clone, blobs unavailable offline), so it could not be confirmed. The mainnet fork test and the V3 dry-run script need MAINNET_RPC_URL and were not run (no RPC in this environment); the mainnet BMC token, coordinator, subscription id 2785...2985 and key hash 0x3fd2fec1... in the dry-run script are therefore unverified here. Coverage gaps observed in the suites that were read: no test models a request that is never fulfilled after expiry, subscription cancellation/consumer removal, or trading between seed delivery and finalization; the V3 tests assert the stall and the unregistered lane as expected behaviour rather than as failures.","line":26,"path":"test/BMCMainnetFork.t.sol","reproduction":"Run `forge test --no-match-contract BMCMainnetForkTest` on 17aa386: 43 passed. `git show origin/security/vrf-recovery:test/BMCIlluminatiPassCandidateV3.t.sol` fails with 'could not fetch ... from promisor remote'.","severity":"info","snippet":"    function test_RealBMCGenesisMint() public {","title":"Verification gap: the '80 passed' claim could not be reproduced on the pinned checkout; 43 tests run and pass, fork tests not run"},{"citation":"unresolved","description":"Scope note: the pinned checkout (HEAD 17aa386, main) does not contain src/experimental/* or the branch's test files; the V3 source, its test file, the dry-run script and docs were fetched from branch security/vrf-recovery (bf2a63c) over HTTPS and line numbers refer to that file. Entry points and guards: mintGenesis/mintMystery (anyone, nonReentrant, burn verified by supply+balance deltas, CEI then _safeMint); closeSale (anyone, only when sold out or past the immutable deadline; no early-close power exists); requestSeed (anyone, one-shot); rawFulfillRandomWords (msg.sender == immutable coordinator, requestId == seedRequestId, idempotent); finalizeReveals (anyone, strict id order, no external calls); transferOwnership/renounceOwnership (owner; the owner has no other function in V3). ERC-721 transfers are standard. Reentrancy: the onERC721Received hook in mintMystery can call closeSale/requestSeed on the 500th mint, but all mint state is already written and the effect equals a separate transaction (no harm found). Rarity: virtual Fisher-Yates over 460 slots draws without replacement; 37 Genesis (artwork 41-77) and 423 Standard (1-423) outcomes are each assigned at most once, verified by the branch's full-inventory test and by inspection (picked in [0,460) unique). BMC burnFrom: OZ ERC20Burnable semantics assumed; guard fails closed on fee-on-transfer/sink tokens; assert(totalBurned <= 201,000,000e18) holds. Trusted parties: BMC token (immutable, assumed non-upgradeable per interface comment, unverified here), Chainlink coordinator and node for the 500 gwei lane, the VRF subscription owner (see finding 4), IPFS pinning of the Genesis directory. Verdict: not mainnet-ready. The file header says NOT FOR DEPLOYMENT, and findings 1-2 (high) leave a realistic path where all 460 mystery mints (161,000,000 BMC burned) are permanently unrevealable with no remedy.","line":14,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Inventory is derived from reading src/experimental/BMCIlluminatiPassCandidateV3.sol on branch security/vrf-recovery; no failing input (informational).","severity":"info","snippet":"/// @dev EXPERIMENTAL V3 CANDIDATE - NOT FOR DEPLOYMENT. Single VRF request; liveness and deployment review outstanding.","title":"V3 access-control inventory and trust assumptions (mainnet readiness: NOT READY)"}],"hash":"3c992377d71b7cac16fef379ed45bf03d3478d9fbe6290160ce50b1e177d144e","nodeId":"9d2953c1-28cd-4b83-8b1a-4cc8502f8006","outcome":"completed","summary":"","treeHash":null,"usage":{"cachedInputTokens":2647306,"inputTokens":932,"model":"claude-fable-5-1","outputTokens":83132,"runtime":"claude","turns":61,"wallClockMs":1219217}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"b273d407784470b4","findings":[{"citation":"resolved","description":"Each mystery mint opens exactly one Chainlink VRF v2.5 request (mintMystery, line 253) and the contract has no re-request, timeout or fallback for it (contract doc, lines 36-39). finalizeReveals() (line 319-328) advances only in strict mint order and stops at the first token whose status is not RandomnessStored, so one request that the coordinator never fulfils freezes that token AND every token minted after it, even when their own random words have already been delivered and stored. A request becomes permanently unfulfillable in several ways, none recoverable on-chain: (a) setVRFConfig (line 196-217) accepts any non-zero keyHash and the VRFCoordinatorV2_5 deliberately does not validate the key hash at request time ('requests for invalid keyHashes will simply not be fulfilled'), so a mint under a mistyped or retired gas lane commits a request that no node will ever serve; correcting the config afterwards only affects future requests (line 195); (b) Chainlink deregisters the proving key of the configured lane while requests are pending (fulfillRandomWords reverts NoSuchProvingKey); (c) the subscription owner cancels or migrates the subscription, or the coordinator is deprecated; (d) Chainlink drops a request that stays below the minimum subscription balance for 24 hours. Impact: up to 460 pending NFTs (161,000,000 BMC irreversibly burned through burnFrom) remain 'Pending Reveal' forever; the owner has no power to unblock them. Fix (preserves the in-order fairness design): record the request block/timestamp per token; let finalizeReveals() skip a token whose request has been unfulfilled for a bounded window (e.g. 7 days) so delivered tokens can be finalized, and add a permissionless retryRequest(tokenId) that issues a fresh request for such a token under the current config, accepting only the newest requestId in rawFulfillRandomWords so a late delivery of the stale request is ignored. The random words are VRF-committed, so skipping a provably stale predecessor does not let a keeper choose outcomes.","line":321,"path":"src/BMCIlluminatiPass.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {ERC20Burnable} from \"@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol\";\nimport {VRFV2PlusClient} from \"@chainlink/contracts/src/v0.8/vrf/dev/libraries/VRFV2PlusClient.sol\";\nimport {BMCIlluminatiPass} from \"src/BMCIlluminatiPass.sol\";\n\n/// @dev Plain 18-decimal ERC20Burnable, same semantics as mainnet BMC.\ncontract ProofBMC is ERC20, ERC20Burnable {\n    constructor() ERC20(\"BMC Illuminati\", \"BMC\") {}\n\n    function mint(address to, uint256 amount) external {\n        _mint(to, amount);\n    }\n}\n\ninterface IProofConsumer {\n    function rawFulfillRandomWords(uint256 requestId, uint256[] calldata randomWords) external;\n}\n\n/// @dev Minimal VRF v2.5 coordinator stand-in. Like the real VRFCoordinatorV2_5, requestRandomWords\n///      does NOT validate the key hash (\"requests for invalid keyHashes will simply not be fulfilled\").\n///      Fulfilment is only possible for requests made under the registered lane; a request made under\n///      any other key hash (typo, retired gas lane, deregistered proving key) stays pending forever.\ncontract ProofCoordinator {\n    bytes32 public constant REGISTERED_LANE = keccak256(\"registered lane\");\n    uint256 public nextRequestId = 1000;\n    mapping(uint256 => address) public consumerOf;\n    mapping(uint256 => bytes32) public laneOf;\n\n    function requestRandomWords(VRFV2PlusClient.RandomWordsRequest calldata req) external returns (uint256 id) {\n        id = nextRequestId++;\n        consumerOf[id] = msg.sender;\n        laneOf[id] = req.keyHash;\n    }\n\n    function fulfill(uint256 requestId, uint256 word) external returns (bool ok) {\n        require(laneOf[requestId] == REGISTERED_LANE, \"NoSuchProvingKey\");\n        uint256[] memory words = new uint256[](1);\n        words[0] = word;\n        (ok,) = consumerOf[requestId].call{gas: 500_000}(\n            abi.encodeCall(IProofConsumer.rawFulfillRandomWords, (requestId, words))\n        );\n    }\n}\n\n/// @title Proof: one unfulfillable VRF request permanently stalls every later mystery reveal.\n/// @notice Fails on the current code: token 41's request was made under a key hash the coordinator\n///         never fulfils; tokens 42..50 have their random words stored, yet finalizeReveals() reveals\n///         none of them because it only advances in strict mint order and there is no re-request or\n///         timeout for the stuck token. Passes once a token whose own randomness has been delivered\n///         can be finalized after its predecessor's request has stayed unfulfilled for a bounded time.\ncontract ProofRevealStallTest is Test {\n    ProofBMC bmc;\n    ProofCoordinator coord;\n    BMCIlluminatiPass pass;\n    address alice = makeAddr(\"alice\");\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        bmc = new ProofBMC();\n        coord = new ProofCoordinator();\n        pass = new BMCIlluminatiPass(address(this), address(bmc), address(coord));\n        pass.setGenesisImagesCID(\"bafyProofCID\");\n        // Key hash that the coordinator accepts at request time but will never fulfil.\n        pass.setVRFConfig(1, keccak256(\"retired lane\"), 500_000, 3, true);\n\n        bmc.mint(alice, 201_000_000 ether);\n        vm.startPrank(alice);\n        bmc.approve(address(pass), type(uint256).max);\n        for (uint256 i; i < 40; ++i) pass.mintGenesis();\n        vm.stopPrank();\n    }\n\n    function test_DeliveredTokensRevealDespiteOneStuckRequest() public {\n        // Token 41: request committed under the unfulfillable lane (owner typo / lane retired later).\n        vm.prank(alice);\n        (uint256 stuckToken, uint256 stuckRequest) = pass.mintMystery();\n        assertEq(stuckToken, 41);\n\n        // Owner corrects the lane. setVRFConfig only affects future requests (contract doc, line 195).\n        pass.setVRFConfig(1, coord.REGISTERED_LANE(), 500_000, 3, true);\n\n        // Tokens 42..50: requests under the registered lane, all fulfilled by the coordinator.\n        uint256[9] memory ids;\n        for (uint256 i; i < 9; ++i) {\n            vm.prank(alice);\n            (, ids[i]) = pass.mintMystery();\n        }\n        for (uint256 i; i < 9; ++i) {\n            assertTrue(coord.fulfill(ids[i], uint256(keccak256(abi.encode(i))) ));\n        }\n        for (uint256 t = 42; t <= 50; ++t) {\n            assertEq(uint256(pass.revealStatusOf(t)), uint256(BMCIlluminatiPass.RevealStatus.RandomnessStored));\n        }\n\n        // The stuck request can never be fulfilled by the coordinator.\n        vm.expectRevert(bytes(\"NoSuchProvingKey\"));\n        coord.fulfill(stuckRequest, 1);\n\n        // Thirty days later, anyone tries to finalize. Expected: the nine tokens whose randomness was\n        // delivered are revealed. Actual (current code): zero reveals; 3,150,000 BMC burned for tokens\n        // 42..50 (and every later mystery mint) stay \"Pending Reveal\" forever.\n        vm.warp(block.timestamp + 30 days);\n        pass.finalizeReveals(100);\n        assertEq(uint256(pass.revealStatusOf(42)), uint256(BMCIlluminatiPass.RevealStatus.Revealed), \"token 42 stuck behind token 41\");\n        assertEq(pass.revealedCount(), 9, \"delivered tokens must be revealable\");\n    }\n}","reproduction":"State: Genesis phase complete (tokens 1-40), owner has set VRF config with keyHash K1 that the coordinator will never fulfil (typo, or a gas lane later retired). Steps: (1) alice calls mintMystery() -> token 41, request R1 under K1, 350,000 BMC burned; (2) owner calls setVRFConfig(.., K2 registered, ..); (3) alice mints tokens 42..50 -> requests R2..R10 under K2; (4) coordinator fulfils R2..R10 -> revealStatusOf(42..50) == RandomnessStored; R1 can never be fulfilled; (5) 30 days later anyone calls finalizeReveals(100). Expected: tokens 42..50 (whose words are stored) are Revealed, or some path exists to re-request randomness for token 41. Actual: finalizeReveals returns 0, revealedCount stays 0, revealStatusOf(42) == RandomnessStored, and no function (owner or public) can change that; every further mystery mint also stays pending. The attached test fails on the current code with 'token 42 stuck behind token 41: 2 != 3'.","severity":"high","snippet":"        while (done < maxCount && tokenId <= totalMinted && _revealStatus[tokenId] == RevealStatus.RandomnessStored) {","title":"No retry or timeout for an unfulfillable VRF request: one stuck mystery token permanently stalls every later reveal"},{"citation":"unresolved","description":"The constructor only range-checks the VRF parameters and then freezes them (no setter, immutable coordinator). Neither the constructor nor mintMystery() asks the coordinator whether subscriptionId exists, whether this contract is an active consumer of it, or whether it is funded. The first and only point where a bad configuration surfaces is requestSeed(), which runs after the sale has closed and after every buyer has already burned 350,000 BMC per token. With a nonexistent subscription id (any typo in the 77-digit constant), a subscription owned by a third party who never adds the pass as consumer, or a lane deregistered before close, requestSeed() reverts forever (InvalidSubscription / InvalidConsumer / 'unregistered gas lane') and the burned BMC bought tokens that can never reveal. Verified on a mainnet fork with the exact DryRun parameters: deploying, minting 40 Genesis plus one mystery (40,350,000 BMC burned) succeeds, then requestSeed() reverts with InvalidConsumer(subId, pass) because the live subscription has no consumers. Fix: in mintMystery() (or at least in the constructor via a CREATE2-precomputed address) call VRF_COORDINATOR.getSubscription(subscriptionId) and require that address(this) is among the consumers and that the relevant balance is above a configured minimum; revert the mint otherwise so buyers never burn into an unrevealable collection.","line":234,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Fork of Ethereum mainnet. Deploy BMCIlluminatiPassCandidateV3 with the DryRun constants (subscription 27852459484169864437638942739291618120641810244677084392617474192083559562985, key 0x3fd2fec1..., 500000, 3, true). Mint 40 Genesis and call mintMystery() from a funded buyer: succeeds, totalBurned = 40,350,000e18. Warp to saleDeadline and call requestSeed(): reverts InvalidConsumer(subId, address(pass)). Expected: the mint should have refused to burn while the pass cannot request randomness. Actual: BMC burned, token 41 pending. With subscriptionId 123 instead, the revert is InvalidSubscription and no party can ever repair it.","severity":"medium","snippet":"        _burnExact(msg.sender, MYSTERY_PRICE);","title":"Mystery mints burn BMC irreversibly without any check that the immutable VRF configuration can ever deliver a seed"},{"citation":"unresolved","description":"The simulation checks only that the two mainnet addresses have code. It does not read getSubscription(SUBSCRIPTION_ID) to confirm the subscription exists, who owns it, that it has (or will have) the pass as a consumer, that its funding type matches nativePayment=true, or that the balance meets the Chainlink minimum for the chosen callback gas and lane; nor does it read s_provingKeys(KEY_HASH). Run today against mainnet the script reports 'Script ran successfully' while the subscription has 0 consumers, 0 LINK and 0.02 ETH (minimum 0.434 ETH). A green CI run therefore gives no assurance that the deployment can ever reveal. Fix: add getSubscription and s_provingKeys reads with requires (owner == expected signer, nativeBalance >= computed minimum, key exists) and print the minimum balance.","line":25,"path":"script/DeployBMCIlluminatiPassV3DryRun.s.sol","reproduction":"OWNER=0x83027AD9d0D5374335086c539dADD1db983441B0 forge script script/DeployBMCIlluminatiPassV3DryRun.s.sol --fork-url <mainnet> -> 'Script ran successfully'. In the same fork, getSubscription(SUBSCRIPTION_ID) returns (balance 0, nativeBalance 20000000000000000, reqCount 0, owner 0x83027AD9d0D5374335086c539dADD1db983441B0, consumers []). Expected: the dry run fails or warns. Actual: passes.","severity":"low","snippet":"        require(BMC.code.length > 0 && COORDINATOR.code.length > 0, \"missing mainnet contract code\");","title":"Dry-run deployment script validates nothing about the VRF subscription it hard-codes"},{"citation":"unresolved","description":"In V3 the callback only stores two words and emits an event: measured 53,880 gas on a mainnet fork (53,599 with the mock). The 500,000 value and the README note that '500k callback gas lets each callback finalize up to 4 reveals' come from the per-mint design of src/BMCIlluminatiPass.sol and no longer apply. Chainlink reserves laneMaxGasPrice x (200,000 + callbackGasLimit) x 1.24 before fulfilling: 0.434 ETH at 500 gwei with 500,000, versus 0.217 ETH with 150,000 (the contract's own MIN_CALLBACK_GAS_LIMIT). The oversized reserve doubles the funding the subscription must hold at close and widens the unfunded window exploited in the stall finding. Fix: set the script's callbackGasLimit to 150,000 (and consider lowering MIN_CALLBACK_GAS_LIMIT toward ~120,000 now that the callback does no reveals).","line":37,"path":"script/DeployBMCIlluminatiPassV3DryRun.s.sol","reproduction":"Deploy with the DryRun parameters, request the seed, prank the coordinator and call rawFulfillRandomWords(req, [777]) measuring gasleft() before/after: 53,880 gas used. Required minimum balance with 500,000: 500e9 x 700,000 x 124/100 = 434,000,000,000,000,000 wei; with 150,000: 217,000,000,000,000,000 wei. Expected: callback limit sized to the callback. Actual: 9x oversized.","severity":"low","snippet":"            500_000,","title":"callbackGasLimit of 500,000 is nine times the measured callback cost and inflates the Chainlink minimum balance to 0.434 ETH"},{"citation":"unresolved","description":"If the deadline passes with totalMinted <= 40 (Genesis not sold out, or nothing sold), there is nothing to reveal, yet any address can call requestSeed(), which closes the sale and sends a real randomness request. The coordinator then charges the subscription the fulfilment cost (gas x 1.24 premium; roughly 0.006 ETH at 20 gwei, up to ~0.17 ETH at the lane's 500 gwei cap) for a word that finalizeReveals() will never use, and the one-shot request slot is consumed. Fix: require(totalMinted > GENESIS_PHASE_SUPPLY, 'nothing to reveal') in requestSeed().","line":253,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Fresh deployment, no mints. Warp to saleDeadline. Call requestSeed() from any EOA: returns a non-zero request id, saleClosed = true, pendingReveals() = 0. Coordinator fulfils and charges the subscription; finalizeReveals(100) returns 0. Expected: revert because no mystery token was sold. Actual: paid request issued.","severity":"low","snippet":"        if (!saleClosed) closeSale();","title":"requestSeed() issues a paid VRF request even when no mystery token exists"},{"citation":"unresolved","description":"Once SeedReceived is emitted the result of every sold mystery token is fixed and reproducible off-chain (keccak256(abi.encode(globalSeed, tokenId)) % remaining, walked in token-id order with the virtual Fisher-Yates table). Finalization is permissionless but unincentivised: 460 reveals cost roughly 25M gas, so tokens can stay 'Pending Reveal' for a while after the seed is public. During that window an informed buyer can purchase exactly the tokens that will become Genesis (37 of 460, 8.0%) from holders who have not computed their result. This does not let anyone change an outcome and the pending metadata text warns about it, so it is recorded as an economic trust note rather than a defect. Mitigation options: finalize the first batch inside the callback (the gas budget exists) and have the team finalize the rest in the same block as fulfilment, or make metadata reflect computed outcomes immediately via a view that derives the tier from globalSeed.","line":308,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"After rawFulfillRandomWords stores globalSeed, call finalizeReveals(0) (no-op) and compute off-chain for tokenId 41: r = keccak256(abi.encode(globalSeed, 41)) % 460; tier = r < 37 ? Genesis : Standard. Then call finalizeReveals(1): tierOf(41) equals the precomputed tier. Transfers of token 41 before that call succeed.","severity":"info","snippet":"        globalSeed = randomWords[0];","title":"Every pending token's outcome is computable from globalSeed before finalizeReveals, while tokens stay transferable"},{"citation":"unresolved","description":"V3 moved to a single post-sale request but kept the per-token scaffolding: requestIdOf, tokenIdOfRequest and _randomWord are never written (the delete in _finalize is a cold no-op SSTORE on every reveal), RandomnessStored (status and event), GAS_PER_REVEAL, CALLBACK_GAS_RESERVE, CALLBACK_REVEAL_BATCH, GenesisCIDNotSet, GenesisCIDAlreadySet and VRFNotConfigured are unreachable or unused, and the vrfConfig.configured flag is always true. MysteryMinted emits requestId = 0 and revealStatusOf() reports AwaitingRandomness for a token minted before any request exists, which misleads indexers and the website's reveal lookup. Fix: remove the dead members, drop the requestId field from MysteryMinted, and add a status value for 'sale open, no request yet'.","line":335,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Mint 40 Genesis then mintMystery(): the MysteryMinted event carries requestId 0; revealStatusOf(41) == AwaitingRandomness while seedRequestId() == 0. Each _finalize call performs delete _randomWord[tokenId] on a slot that was never set.","severity":"info","snippet":"        delete _randomWord[tokenId];","title":"Leftover per-mint VRF state and misleading signals: requestId 0 in MysteryMinted, AwaitingRandomness before any request, dead storage writes"},{"citation":"unresolved","description":"No function is gated by onlyOwner; owner() exists only for marketplace metadata, with transferOwnership/renounceOwnership as the only owner actions. This is consistent with the stated trust model (no owner mint, price, metadata, withdraw or upgrade path) and is recorded so that reviewers do not look for owner powers that are not there. The real privileged party is the VRF subscription owner (0x83027AD9d0D5374335086c539dADD1db983441B0 on mainnet) who must add the pass as consumer and keep the subscription funded; the README's 'Privileged powers' table still describes the V1 setters (setGenesisImagesCID, setVRFConfig) that V3 does not have.","line":189,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"grep onlyOwner src/experimental/BMCIlluminatiPassCandidateV3.sol returns nothing; owner() returns initialOwner; README 'Privileged powers' lists setVRFConfig which does not exist in V3.","severity":"info","snippet":"    ) ERC721(\"BMC Illuminati Pass\", \"BMCPASS\") Ownable(initialOwner) {","title":"Ownable is inherited but the owner has no powers; document as a trust note"},{"citation":"unresolved","description":"The 80 passing tests were re-run independently (80 passed, 0 failed, plus the V1 mainnet fork test passing with a public RPC), but only 9 of them target V3 and none covers: a seed request that is never fulfilled (the suite asserts the second request reverts and then fulfils the first, i.e. it tests the stall as intended behaviour), a sale closed by deadline with fewer than 460 mystery tokens followed by reveal, requestSeed() with zero mystery tokens, a stale redelivery after seedReceived, or V3 against the real coordinator/subscription on a fork (the existing fork test and the ChainlinkMockIntegration tests only cover src/BMCIlluminatiPass.sol). The DryRun CI step is skipped when MAINNET_RPC_URL is unset in the fork test step but hard-fails in the simulation step, so CI behaviour depends on the secret.","line":17,"path":"test/BMCIlluminatiPassCandidateV3.t.sol","reproduction":"forge test --match-path test/BMCIlluminatiPassCandidateV3.t.sol lists 9 tests; none warps past an unfulfilled request and asserts recovery, none mints fewer than 460 mystery tokens before reveal, none deploys V3 on a fork.","severity":"info","snippet":"contract BMCIlluminatiPassCandidateV3Test is Test {","title":"V3 test suite does not exercise the paths where the design fails: dropped request, partial sale, empty sale, live coordinator"}],"hash":"530011b3cbe4319273110669ee69787ac153b8f03a6ab19bc45dde9009fea357","nodeId":"ddb845b5-9689-40fa-9572-ce8ddaf4bc77","outcome":"completed","summary":"The flagged finding is corrected in place in `.imd-findings.json`. Nothing else in the tree changed.\n\n**What was wrong.** The checked-out tree is the pinned commit on main (17aa386), which has no `src/experimental/` directory. The V3 candidate and its tests exist only on the remote `security/vrf-recovery` branch, so the high finding's path could not be matched.\n\n**What I did.** The same defect exists in the production contract at the pinned commit, so the finding now points there:\n\n- Path and line: `src/BMCIlluminatiPass.sol:321`, the strict in-order loop condition in `finalizeReveals`, with the snippet copied verbatim from the file.\n- Mechanism restated for main: each mystery mint opens exactly one VRF request with no retry or timeout, and finalization only advances in mint order. One request the coordinator never fulfils freezes that token and every later token, even those whose random words are already stored. The deterministic trigger is a key hash the coordinator accepts at request time but never serves, which `setVRFConfig` does not validate and which correcting the config afterwards cannot repair.\n- Proof replaced: `test/scratch/ProofRevealStall.t.sol` imports only `src/BMCIlluminatiPass.sol` and forge-std, inlines its own BMC and coordinator mocks, and fails on current code with `token 42 stuck behind token 41: 2 != 3`. The old V3 proof was removed from scratch.\n- Severity stays high. The recommended fix is a bounded staleness window after which delivered tokens can be finalized, plus a permissionless re-request for the stuck token that accepts only the newest request id.\n\n**Left as is.** The other eight findings still cite the V3 files on the `security/vrf-recovery` branch named in the brief. They were not flagged and remain in the record as notes against that branch. Scratch output stays only under `test/scratch/`, which is ignored and discarded.","treeHash":null,"usage":{"cachedInputTokens":2659021,"inputTokens":868,"model":"claude-fable-5-1","outputTokens":69229,"runtime":"claude","turns":43,"wallClockMs":1028376}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"fbcdfc017217af1f","findings":[{"citation":"resolved","description":"requestSeed() can be called by anyone the moment block.timestamp >= saleDeadline (or at the 500th mint, even from inside the minter's onERC721Received hook). It makes the one and only VRF request and stores seedRequestId; afterwards `require(seedRequestId == 0)` refuses any further request forever, and finalizeReveals() requires seedReceived. The contract has no timeout, no re-request and no way to accept a later request id, so the whole mystery collection (up to 460 tokens, 161,000,000 BMC irreversibly burned) depends on that single request being fulfilled. Chainlink VRF v2.5 does NOT check the subscription balance when the request is made (verified on a mainnet fork at block 26176844: after the subscription owner adds the pass as consumer, requestRandomWords succeeds although subscription 27852459...562985 holds 0 LINK and only 0.02 ETH native). Fulfilment is only attempted while the balance covers the lane's maximum cost, and Chainlink documents that 'if your balance is below the minimum, your requests remain pending for up to 24 hours before they expire' (docs.chain.link/vrf/v2-5/overview/subscription). An expired request is never delivered, the coordinator keeps its commitment (so cancelSubscription/removeConsumer revert with PendingRequestExists, verified on the fork), and the pass is left with seedRequestId != 0 and seedReceived == false permanently. The README's statement that VRF 'keeps a request pending while the subscription is unfunded and fulfils it after funding' is therefore incorrect. Because the call is permissionless, the operator cannot sequence 'fund, then request': a griefer or an eager holder can fire the request in the first block after the deadline, during a gas spike above the lane cap, or before the operator has topped up. A gas spike that outlasts the 24h window has the same effect. The design is acknowledged in the contract comments ('A permanently unfulfilled request may permanently prevent reveals'), so fixing it is a scope decision: the stated single-request rule has to be relaxed. Minimal fix preserving fairness: record seedRequestedAt and allow requestSeed() again once e.g. 2 days have passed without seedReceived; accept delivery for any request id this contract issued (mapping issuedRequest[requestId]) and keep the first word delivered (seedReceived guard already does this). Optionally restrict requestSeed to the owner for the first 24h after close so funding can be verified first.","line":255,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {BMCIlluminatiPassCandidateV3} from \"src/experimental/BMCIlluminatiPassCandidateV3.sol\";\n\n/// @dev Minimal 18-decimal token with OpenZeppelin-style burnFrom (spend allowance, destroy tokens).\ncontract ProofBMC {\n    uint256 public totalSupply;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function decimals() external pure returns (uint8) {\n        return 18;\n    }\n\n    function mint(address to, uint256 amount) external {\n        totalSupply += amount;\n        balanceOf[to] += amount;\n    }\n\n    function approve(address spender, uint256 amount) external returns (bool) {\n        allowance[msg.sender][spender] = amount;\n        return true;\n    }\n\n    function burnFrom(address account, uint256 amount) external {\n        uint256 a = allowance[account][msg.sender];\n        require(a >= amount, \"allowance\");\n        if (a != type(uint256).max) allowance[account][msg.sender] = a - amount;\n        require(balanceOf[account] >= amount, \"balance\");\n        balanceOf[account] -= amount;\n        totalSupply -= amount;\n    }\n}\n\n/// @dev Coordinator stand-in that accepts requests but, like a real node facing an underfunded\n///      subscription for more than 24h, never delivers the first one. It can deliver later requests.\ncontract NeverFulfillsFirstCoordinator {\n    /// @dev Mirrors VRFV2PlusClient.RandomWordsRequest (same ABI tuple, same selector).\n    struct RandomWordsRequest {\n        bytes32 keyHash;\n        uint256 subId;\n        uint16 requestConfirmations;\n        uint32 callbackGasLimit;\n        uint32 numWords;\n        bytes extraArgs;\n    }\n\n    uint256 public nextRequestId = 1000;\n    mapping(uint256 => address) public consumerOf;\n\n    function s_provingKeys(bytes32) external pure returns (bool exists, uint64 maxGas) {\n        return (true, 500 gwei);\n    }\n\n    function requestRandomWords(RandomWordsRequest calldata) external returns (uint256 id) {\n        id = nextRequestId++;\n        consumerOf[id] = msg.sender;\n    }\n\n    function fulfill(uint256 requestId, uint256 word) external returns (bool ok) {\n        uint256[] memory words = new uint256[](1);\n        words[0] = word;\n        (ok,) = consumerOf[requestId].call(\n            abi.encodeWithSignature(\"rawFulfillRandomWords(uint256,uint256[])\", requestId, words)\n        );\n    }\n}\n\ncontract V3SeedStallProof is Test {\n    ProofBMC bmc;\n    NeverFulfillsFirstCoordinator coord;\n    BMCIlluminatiPassCandidateV3 pass;\n    address alice = makeAddr(\"alice\");\n    address griefer = makeAddr(\"griefer\");\n    uint64 deadline;\n    uint256 firstRequest;\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        deadline = uint64(block.timestamp + 30 days);\n        bmc = new ProofBMC();\n        coord = new NeverFulfillsFirstCoordinator();\n        pass = new BMCIlluminatiPassCandidateV3(\n            address(this), address(bmc), address(coord), deadline, 123, keccak256(\"lane\"), 500_000, 3, true, \"bafyCID\"\n        );\n        bmc.mint(alice, 201_000_000 ether);\n        vm.startPrank(alice);\n        bmc.approve(address(pass), type(uint256).max);\n        for (uint256 i; i < 40; ++i) pass.mintGenesis();\n        for (uint256 i; i < 10; ++i) pass.mintMystery();\n        vm.stopPrank();\n\n        // Sale deadline passes. Anyone (here an unrelated griefer) fires the one and only VRF request\n        // while the subscription is not yet able to pay for fulfilment. The node never fulfils it.\n        vm.warp(deadline);\n        vm.prank(griefer);\n        firstRequest = pass.requestSeed();\n        assertEq(firstRequest, 1000);\n    }\n\n    /// @notice 10 holders burned 3,500,000 BMC for tokens 41..50. After the first request has gone\n    ///         unfulfilled for far longer than Chainlink's 24h pending window, the collection must be\n    ///         able to obtain randomness again. On the current code the second request is refused\n    ///         forever and finalizeReveals can never run.\n    function test_CanRecoverFromUnfulfilledSeedRequest() public {\n        vm.warp(deadline + 7 days);\n        assertFalse(pass.seedReceived());\n        assertEq(pass.pendingReveals(), 10);\n\n        vm.prank(alice);\n        uint256 secondRequest = pass.requestSeed(); // reverts \"seed already requested\" today\n        assertTrue(secondRequest != 0 && secondRequest != firstRequest, \"no fresh request\");\n\n        assertTrue(coord.fulfill(secondRequest, 0xBEEF));\n        assertTrue(pass.seedReceived(), \"seed never received\");\n        assertEq(pass.finalizeReveals(10), 10);\n        assertEq(pass.pendingReveals(), 0);\n        assertTrue(pass.tierOf(41) != BMCIlluminatiPassCandidateV3.Tier.Pending);\n    }\n}","reproduction":"State: 40 Genesis + 10 mystery minted (3,500,000 BMC burned for ids 41-50); block.timestamp reaches saleDeadline; the VRF subscription is below Chainlink's minimum balance (mainnet today: 0.02 ETH native vs ~0.434 ETH needed for the 500 gwei lane with callbackGasLimit 500000). Input: any address calls requestSeed() -> request id R stored. The node never fulfils R (expires after 24h). 7 days later anyone calls requestSeed() again. Expected: a fresh request can be issued (or some recovery path exists) so the 10 tokens can be revealed. Actual: revert 'seed already requested'; finalizeReveals(n) reverts 'seed pending'; tierOf(41..50) == Pending forever. Proof: test/scratch/V3SeedStallProof.t.sol fails on this code with 'seed already requested' and passes once a timed re-request is allowed.","severity":"high","snippet":"        require(seedRequestId == 0, \"seed already requested\");","title":"Single, permissionless, irrevocable VRF seed request: one unfulfilled request permanently blocks every reveal"},{"citation":"resolved","description":"The deployment parameters fixed in the constructor cannot be changed afterwards (VRFConfig is set once, no setter in V3). Read from Ethereum mainnet (block 26176844, coordinator 0xD7f8...893a): KEY_HASH 0x3fd2fec1...ff10b is registered with maxGas = 500 gwei (it is the 500 gwei lane; 0x8077df51... is the 200 gwei lane); SUBSCRIPTION_ID 27852459...562985 exists, owner 0x83027AD9d0D5374335086c539dADD1db983441B0, LINK balance 0, native balance 0.02 ETH, 0 consumers, reqCount 0. nativePayment=true so only the native balance counts. Chainlink's minimum balance before a node will fulfil is lane max gas price x (max verification gas 200k + callbackGasLimit) x 1.24 (native premium 24%, read from s_config): 500 gwei x 700,000 x 1.24 = ~0.434 ETH. The subscription holds 0.02 ETH, i.e. 4.6% of that, so a request made with this configuration today would sit pending and expire after 24h (see the high finding). callbackGasLimit = 500_000 was sized for V1, whose callback finalized up to 4 reveals inline. V3's rawFulfillRandomWords only writes seedReceived/globalSeed and emits one event: measured 53,593 gas (test/scratch/V3Checks.t.sol, test_CallbackGasMeasurement); a 60,000-gas delivery succeeds. Every extra unit of callback gas raises the required pre-funding linearly and is charged as reserved gas at fulfilment. The dry-run script asserts owner/deadline/CID but reads nothing from the coordinator, so this state is not caught by CI. Fix: set callbackGasLimit to MIN_CALLBACK_GAS_LIMIT (150_000) or slightly above, consider the 200 gwei lane (minimum ~0.087 ETH at 150k), fund the subscription with >= 0.5 ETH native (or switch to LINK and nativePayment=false) BEFORE the sale deadline, and extend DeployBMCIlluminatiPassV3DryRun to require getSubscription(SUBSCRIPTION_ID).nativeBalance >= the computed minimum and s_provingKeys(KEY_HASH).exists.","line":37,"path":"script/DeployBMCIlluminatiPassV3DryRun.s.sol","reproduction":"Inputs: the constants in script/DeployBMCIlluminatiPassV3DryRun.s.sol (SUBSCRIPTION_ID, KEY_HASH, callbackGasLimit 500_000, nativePayment true) against mainnet state at block 26176844. `cast call 0xD7f86b4b8Cae7D942340FF628F82735b7a20893a 'getSubscription(uint256)(uint96,uint96,uint64,address,address[])' 27852459484169864437638942739291618120641810244677084392617474192083559562985` returns (0, 20000000000000000, 0, 0x83027AD9..., []). `cast call ... 's_provingKeys(bytes32)(bool,uint64)' 0x3fd2fec1...ff10b` returns (true, 500000000000). Expected: a configuration whose subscription can pay for the single fulfilment (>= 0.434 ETH native at 500k callback gas on the 500 gwei lane). Actual: 0.02 ETH, request would never be fulfilled; the dry run (`forge script ... --fork-url`) still reports 'Script ran successfully'. Fork test test/scratch/V3MainnetFork.t.sol shows the real coordinator accepts the request in this state.","severity":"medium","snippet":"            500_000,","title":"Dry-run deployment config points at an underfunded subscription on the 500 gwei lane with a 9x oversized callback gas limit"},{"citation":"resolved","description":"Once rawFulfillRandomWords stores globalSeed, every mystery outcome is a pure function of (globalSeed, tokenId) and the Fisher-Yates state, so anyone can simulate _finalize for tokens 41..totalMinted off-chain in the same block. Nothing on-chain reflects this until someone pays for finalizeReveals (about 85k gas per token, ~39M gas for 460 tokens, so several transactions). Pending tokens listed on marketplaces during that window can be bought by informed parties at Standard prices while the seller sees 'Pending Reveal'. The _pendingJson text discloses the risk but tokenURI still shows the placeholder. V1 mitigated this by finalizing a gas-bounded batch inside the callback; V3 removed it while keeping a 500k callbackGasLimit that would comfortably cover ~4 reveals. Fix: in rawFulfillRandomWords, after storing the seed, call `try this.finalizeReveals(CALLBACK_REVEAL_BATCH)` (the constant still exists) and/or make tokenURI compute the outcome when seedReceived and the token is next in order; alternatively finalize in the same transaction via a keeper immediately after SeedReceived.","line":313,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"State: sale closed with 460 mystery tokens, seed delivered (seedReceived == true, revealedCount == 0). Input: an observer replays _finalize(41..500) off-chain with globalSeed; e.g. with seed 42 the existing test_FullInventoryNoDuplicates shows the exact 37 token ids that become Genesis. Expected: metadata reflects final outcomes as soon as randomness is final, or outcomes are not knowable before they are on-chain. Actual: tierOf(id) == Pending and tokenURI shows 'REVEAL PENDING' for all 460 tokens until finalizeReveals is called by someone; holders who sell in that window sell against informed buyers.","severity":"low","snippet":"    function finalizeReveals(uint256 maxCount) external returns (uint256 done) {","title":"Reveal outcomes are publicly computable between SeedReceived and finalizeReveals; V3 dropped V1's inline batch"},{"citation":"resolved","description":"requestSeed() closes the sale and requests randomness whenever the deadline has passed, without checking that any mystery token was minted. If fewer than 40 Genesis are sold by saleDeadline, the mystery phase never opens, yet anyone can still trigger a paid VRF request from the operator's subscription that reveals nothing (finalizeReveals returns 0). The request is also the one-and-only request, so it consumes the subscription's funds and the coordinator keeps a pending commitment blocking cancelSubscription until fulfilled. Fix: `require(totalMinted > GENESIS_PHASE_SUPPLY, \"nothing to reveal\")` in requestSeed().","line":253,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"State: 1 Genesis minted, block.timestamp >= saleDeadline. Input: anyone calls requestSeed(). Expected: revert, no randomness needed. Actual: coordinator.nextRequestId increments (request made and paid for at fulfilment), seedRequestId set, finalizeReveals(100) returns 0. Shown by test/scratch/V3Checks.t.sol test_RequestSeedWithNoMysterySold.","severity":"low","snippet":"        if (!saleClosed) closeSale();","title":"requestSeed() spends the VRF request even when no mystery token exists (Genesis phase unsold at the deadline)"},{"citation":"resolved","description":"remainingSupply(), remainingMysteryMints(), remainingMysteryGenesis() and remainingMysteryStandard() ignore saleClosed/saleDeadline. After an early close with unsold positions they keep reporting the unsold count as mintable and the undrawn outcomes as 'remaining', although nothing can ever be minted again. The website (website/app.js) reads remainingSupply and remainingMysteryGenesis/Standard to display supply and 'remaining-inventory odds', so a closed sale shows live inventory. Fix: return 0 for remainingSupply/remainingMysteryMints when saleClosed || block.timestamp >= saleDeadline, and either document that remainingMysteryGenesis/Standard count undrawn (never-to-be-minted) outcomes or subtract them once the sale is closed and all sold tokens are finalized.","line":384,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"State: 40 Genesis + 123 mystery minted, deadline passed, seed delivered, all 123 finalized. Expected: remainingSupply() == 0 and remainingMysteryMints() == 0 (sale is over), and a clear 'unsold outcomes' figure. Actual (test/scratch/V3Checks.t.sol test_PartialSaleUniquenessAndViews): remainingSupply() == 337, remainingMysteryMints() == 337, remainingMysteryGenesis() == 22, remainingMysteryStandard() == 315 while currentPrice() == 0 and mintMystery() reverts SoldOut.","severity":"low","snippet":"        return MAX_SUPPLY - totalMinted;","title":"Inventory views report unsold positions and outcomes as still available after the sale has closed"},{"citation":"resolved","description":"requestIdOf, tokenIdOfRequest and _randomWord are never written (public getters always return 0; `delete _randomWord[tokenId]` in _finalize is a no-op); RevealStatus.RandomnessStored, event RandomnessStored, event VRFConfigSet, error GenesisCIDAlreadySet and constants CALLBACK_REVEAL_BATCH, GAS_PER_REVEAL, CALLBACK_GAS_RESERVE are unused; MysteryMinted always emits requestId = 0 so indexers keyed on it break. Ownable is inherited but V3 has no onlyOwner function, so the owner can only transfer/renounce a role with no effect; the README privileged-powers table (setGenesisImagesCID, setVRFConfig) describes V1, not V3. Fix: remove the dead members or document them, drop the requestId parameter from MysteryMinted (or emit the real seedRequestId in SeedRequested only), and either remove Ownable or state in the trust model that the owner has no powers.","line":237,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Input: mintMystery() -> event MysteryMinted(minter, 41, 0, 350000e18); requestIdOf(41) == 0 and tokenIdOfRequest(anything) == 0 for the lifetime of the contract. Expected: events and getters carry meaningful data or do not exist. Actual: constant zeros; `forge inspect BMCIlluminatiPassCandidateV3 methods` lists owner()/transferOwnership with nothing gated.","severity":"info","snippet":"        emit MysteryMinted(msg.sender, tokenId, 0, MYSTERY_PRICE);","title":"Dead storage, events, constants and an owner role with no powers left over from V1"},{"citation":"resolved","description":"Independent run on this branch (forge 1.8.3, solc 0.8.26): `forge test` reports 81 tests, 80 passed, 1 failed; the failure is test/BMCMainnetFork.t.sol which requires chainid 1 and passes only with --fork-url (verified: passes against mainnet). The commissioning note '80 passed, 0 failed' matches the CI run that excludes BMCMainnetForkTest, not a plain `forge test`. docs/TEST-RESULTS.md records 43 tests of the V1 contract only. The 9 V3 tests use a bespoke mock whose s_provingKeys always answers for VALID_KEY; Chainlink's own VRFCoordinatorV2_5Mock has no s_provingKeys, so V3.requestSeed() reverts 'unregistered gas lane' against it (test/scratch/V3Checks.t.sol test_OfficialChainlinkMockRejectsV3KeyCheck), which is why none of the official-mock tests (VRFRecovery*, VRFSafetyProperties, ChainlinkMockIntegration) exercise V3; they all target src/BMCIlluminatiPass.sol. Edges without V3 tests: reentrancy via onERC721Received into closeSale/requestSeed on the 500th mint (works, harmless, untested), non-burning or 6-decimal token against V3, partial sale distribution/uniqueness, callback gas, redelivery of the same request id, requestSeed with zero mystery tokens, views after early close. The README still documents the V1 API (per-mint VRF, setGenesisImagesCID, setVRFConfig).","line":9,"path":"test/BMCIlluminatiPassCandidateV3.t.sol","reproduction":"Run `forge test` on commit bf2a63c without an RPC: 'Ran 12 test suites: 80 tests passed, 1 failed (81 total)'; `forge test --match-contract BMCMainnetForkTest --fork-url <mainnet>` passes. Deploy V3 with Chainlink's VRFCoordinatorV2_5Mock(0.1 ether, 1e9, 4e15), fund and add consumer, warp past deadline, call requestSeed(): expected a request id, actual revert 'unregistered gas lane'.","severity":"info","snippet":"contract V3RegisteredCoordinator is MockVRFCoordinator {","title":"V3 test coverage and recorded results do not match the branch: 81 tests (80 pass, fork test fails without RPC), V3 never run against Chainlink's official mock"}],"hash":"65fee161c5dd04c18bf11e380372d850f285fdd68d7aa830a1b9333514361307","nodeId":"59a273c8-9303-4181-b19e-d32edc34fcd3","outcome":"completed","summary":"The findings file is written and validated. The ignore rule on it comes from the harness's own `.git/info/exclude`, so it is read from disk as intended. Nothing else is outstanding, so here is the report.\n\n## Audit result: Candidate V3 is not Mainnet-ready\n\nFindings are in `.imd-findings.json` (7 entries, one with a Foundry proof). Verdict: **do not deploy as configured**. The contract's mint, burn, rarity and metadata logic is sound. The blocker is the randomness liveness design combined with the current on-chain VRF subscription state.\n\n**Verification performed**\n\n- Checked out `origin/security/vrf-recovery` (commit bf2a63c) since the pinned HEAD was `main`, which lacks the V3 files.\n- `forge test`: 81 tests, 80 passed, 1 failed. The failure is the mainnet fork test, which needs an RPC. It passes with `--fork-url`. The \"80 passed, 0 failed\" claim matches CI, which excludes that test.\n- Mainnet reads over a public RPC at block 26176844 and a fork run of the V3 dry-run script (simulation only, nothing broadcast).\n- Scratch tests under `test/scratch/` for the proof, callback gas, partial-sale uniqueness, reentrancy into `requestSeed`, and a fork flow against the real coordinator.\n- IPFS gateways rate-limited every request, so the Genesis CID content could not be verified from this machine. It remains an open item.\n\n**Findings, ranked**\n\n1. **High. Single irrevocable VRF request can stall every reveal forever** (`src/experimental/BMCIlluminatiPassCandidateV3.sol:255`). Anyone can call `requestSeed()` the second the deadline passes, even from inside the 500th mint's receiver hook. The real coordinator accepts the request with zero LINK and 0.02 ETH on the subscription (confirmed on the fork). Chainlink expires requests the subscription cannot pay for after 24 hours, and the contract then refuses any new request. The README's claim that VRF waits indefinitely for funding is wrong. Proof test fails now with \"seed already requested\" and passes once a timed re-request is allowed. This changes the stated single-request rule, so it is a scope decision for the requester.\n2. **Medium. Deployment config is underfunded and oversized** (`script/DeployBMCIlluminatiPassV3DryRun.s.sol:37`). The key hash is the 500 gwei lane. With a 500k callback gas limit the minimum balance is about 0.434 ETH. The subscription holds 0.02 ETH and has no consumers. The V3 callback measures 53.6k gas, so 500k is roughly 9x oversized. The dry run reads nothing from the coordinator and reports success.\n3. **Low.** Outcomes are publicly computable between seed receipt and `finalizeReveals`. V1's inline batch was dropped.\n4. **Low.** `requestSeed()` spends the only VRF request even when no mystery token exists.\n5. **Low.** Supply and inventory views report unsold positions as available after the sale closes. The website reads them.\n6. **Info.** Dead storage, events and constants from V1, `MysteryMinted` always emits request id 0, and an owner role with no powers.\n7. **Info.** V3 is never exercised against Chainlink's official mock because of the `s_provingKeys` check, and several edges have no V3 tests.\n\n**Reviewed and found sound**: burnFrom accounting with supply and balance deltas, reentrancy guards and ordering, Fisher-Yates uniqueness including a partial sale, Genesis and Standard artwork ranges, deadline boundaries, constructor validation, coordinator interface compatibility, and the mainnet BMC token shape (18 decimals, non-proxy, burnFrom present, no owner or pause).\n\nSources: [Chainlink VRF v2.5 subscription docs](https://docs.chain.link/vrf/v2-5/overview/subscription), [Chainlink VRF v2.5 billing](https://docs.chain.link/vrf/v2-5/billing)","treeHash":null,"usage":{"cachedInputTokens":1322082,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":49839,"runtime":"claude","turns":42,"wallClockMs":793331}}],"verification":[]}