{"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":"92daebdf-60c5-433a-851e-a2ff409d92a3","kind":"audit","nodes":[{"acceptedSubmissionHash":"9967134592b1371568ed4b08a57aea5bbbe482af66f0ef396c1d5b1dcfa88d17","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":"ddd93515b77d9ddb23470e2064586a8a41381f120cc472ca004a936245f8ca53","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":"c813c676da27eab8a3f583814725ae3f4e463f48cc996b569f209324ab048eea","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":"5356913e3987553d420c2dcc3f2a45cdf6facf24634a7d39f9d811da63111cc6","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":"9544a6b0ff5c8dc658fdbe050a7f6a2dd616c73f90dd8d8ad9b0b422214e8dbc","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":"Audit BMC Illuminati PASS Candidate V3 for Ethereum Mainnet readiness.\n\nPrimary target: src/experimental/BMCIlluminatiPassCandidateV3.sol\n\nReview Chainlink VRF v2.5 integration, single-request randomness liveness, permanent reveal-stall risk, irreversible BMC burns, NFT rarity fairness, 30-day sale deadline, Genesis IPFS metadata, access control and deployment configuration.\n\nThe project previously passed 80 Foundry tests and a V3 deployment simulation.\n\nIdentify vulnerabilities by severity, reproduce findings where possible and recommend precise fixes. Clearly distinguish Candidate V3 from older experimental contracts.\n\nAudit only. No modifications or deployment. Perform a comprehensive, read-only smart contract security audit of BMC Illuminati PASS Candidate V3.\n\nRepository: https://github.com/0xSt3w/launch-1356-project-bmc-illuminati-pass\n\nCommit: bf2a63c04d0e73a1a1b9c65a785d921526ec39b0\n\nContract: src/experimental/BMCIlluminatiPassCandidateV3.sol\n\nReview Chainlink VRF v2.5 randomness, permanent reveal-stall risks, BMC token burns, NFT rarity fairness, sale deadlines, metadata integrity, permissions and deployment configuration.\n\nVerify the existing Foundry tests and report any critical, high, medium or low severity vulnerabilities.\n\nDeliver a written security report with recommended fixes and a Mainnet readiness verdict.\n\nAUDIT ONLY. Do not deploy, broadcast transactions, modify contracts or launch additional paid jobs.","parentJobId":null,"planHash":"db991baf7d74976f6fcfe77d7ea53b89648460b160ffa435a59a852c86152005","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"92daebdf-60c5-433a-851e-a2ff409d92a3","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":"50966","feedbackHash":"9399234abe0c6c7c5fb46741a60f85e582c38fd5a81114bdf3aa39e323fbd4ac","nodeKey":"audit_economics","submissionHash":"9967134592b1371568ed4b08a57aea5bbbe482af66f0ef396c1d5b1dcfa88d17","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52164","feedbackHash":"918810a1f3e38e9e8c43c8bbf7394a96bbcd13f798ccf0794703aaaaee9790fe","nodeKey":"audit_flow","submissionHash":"ddd93515b77d9ddb23470e2064586a8a41381f120cc472ca004a936245f8ca53","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52017","feedbackHash":"cc6af133d983dfce79ab6ad8dbe09cbf509c0b307f6d680771bb3ba06ac8a148","nodeKey":"audit_judge","submissionHash":"c813c676da27eab8a3f583814725ae3f4e463f48cc996b569f209324ab048eea","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51023","feedbackHash":"3cecadbaaa149055f5e2ece58e193c1db11707b94309a7d0129199519fe2017e","nodeKey":"audit_math","submissionHash":"5356913e3987553d420c2dcc3f2a45cdf6facf24634a7d39f9d811da63111cc6","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51450","feedbackHash":"3a330863b4552a2194a7b2934ac69422610afa3179e62af61a3ddce3856b7808","nodeKey":"audit_permissions","submissionHash":"9544a6b0ff5c8dc658fdbe050a7f6a2dd616c73f90dd8d8ad9b0b422214e8dbc","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"63508232de67aee9d46e92f039462a4e70a4ce5293e532de801980ec945664b4","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"ca080fd306399669","findings":[{"citation":"resolved","description":"The constructor validates only that keyHash is non-zero. The VRF v2.5 coordinator does not validate key hashes in requestRandomWords (a request for an unknown lane is accepted and never fulfilled), which is why requestSeed() stat-calls s_provingKeys(keyHash) and reverts with 'unregistered gas lane'. But that check runs only after closeSale(), i.e. after up to 201,000,000 BMC has been irreversibly burned, and vrfConfig is immutable in V3 (no setVRFConfig). A wrong key hash at deployment (e.g. the VRF v2.0 mainnet lane 0xff8dedfb...4f92 instead of the v2.5 lane 0x3fd2fec1...f10b, or a Sepolia lane) therefore produces a contract that mints normally for 30 days and then can never request a seed: every mystery token (ids 41-500) stays Tier.Pending forever and 350,000 BMC per token is burned for nothing. The dry-run script also does not check s_provingKeys. Fix (minimal, design-preserving): perform the same s_provingKeys staticcall in the constructor and revert InvalidVRFConfig() if the lane is not registered; additionally assert it in DeployBMCIlluminatiPassV3DryRun.run(). The proof test deploys against a coordinator that reports the lane as unregistered and expects the constructor to revert.","line":193,"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\";\nimport {VRFV2PlusClient} from \"@chainlink/contracts/src/v0.8/vrf/dev/libraries/VRFV2PlusClient.sol\";\n\n/// @dev Stand-in for the mainnet VRF v2.5 coordinator: like the real one, `requestRandomWords`\n///      does not validate the key hash, and `s_provingKeys` reports whether a lane is registered.\ncontract KeyAwareCoordinator {\n    bytes32 public constant REGISTERED = 0x3fd2fec10d06ee8f65e7f2e95f5c56511359ece3f33960ad8a866ae24a8ff10b;\n    uint256 public nextRequestId = 1000;\n\n    function s_provingKeys(bytes32 key) external pure returns (bool exists, uint64 maxGas) {\n        return (key == REGISTERED, key == REGISTERED ? uint64(500 gwei) : 0);\n    }\n\n    function requestRandomWords(VRFV2PlusClient.RandomWordsRequest calldata) external returns (uint256 id) {\n        id = nextRequestId++;\n    }\n}\n\n/// @notice Finding: an unregistered gas lane is accepted by the constructor and only rejected by\n///         `requestSeed()` after the sale has closed, when BMC has already been burned and the VRF\n///         config can no longer be changed. The constructor must reject a key hash the coordinator\n///         does not know, so the misconfiguration fails before any mint instead of after the sale.\ncontract V3UnregisteredKeyHashTest is Test {\n    KeyAwareCoordinator coord;\n\n    // VRF v2 (not v2.5) 500 gwei mainnet lane: a realistic copy-paste mistake.\n    bytes32 constant V2_KEY_HASH = 0xff8dedfbfa60af186cf3c830acbc32c05aae823045ae5ea7da1e45fbfaba4f92;\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        coord = new KeyAwareCoordinator();\n    }\n\n    function _deploy(bytes32 key) internal returns (BMCIlluminatiPassCandidateV3) {\n        return new BMCIlluminatiPassCandidateV3(\n            address(this),\n            address(0xB0C),\n            address(coord),\n            uint64(block.timestamp + 30 days),\n            123,\n            key,\n            500_000,\n            3,\n            true,\n            \"bafybeihfihwwpev4gkqjuqawgtptsv3ynzsokclo6odsolvoa65aqaac2u\"\n        );\n    }\n\n    function test_RegisteredKeyHashDeploys() public {\n        BMCIlluminatiPassCandidateV3 pass = _deploy(coord.REGISTERED());\n        (, bytes32 keyHash,,,, bool configured) = pass.vrfConfig();\n        assertEq(keyHash, coord.REGISTERED());\n        assertTrue(configured);\n    }\n\n    /// @dev Fails on the current code: the constructor accepts any non-zero key hash, so the\n    ///      deployment succeeds and the lane error surfaces only in `requestSeed()` post-sale.\n    function test_UnregisteredKeyHashIsRejectedAtDeployment() public {\n        vm.expectRevert();\n        _deploy(V2_KEY_HASH);\n    }\n}","reproduction":"Deploy BMCIlluminatiPassCandidateV3(owner, BMC, coordinator, now+30d, subId=123, keyHash=0xff8dedfbfa60af186cf3c830acbc32c05aae823045ae5ea7da1e45fbfaba4f92 (VRF v2.0 lane, not registered on the v2.5 coordinator), 500000, 3, true, CID). Expected: constructor reverts (misconfiguration fails before any burn). Actual: deployment succeeds; 40x mintGenesis() and 460x mintMystery() burn 201,000,000 BMC; at saleDeadline every requestSeed() call reverts 'unregistered gas lane'; seedReceived stays false; finalizeReveals() reverts 'seed pending' forever; tokenURI(41..500) permanently returns the Pending JSON. Proof: forge test --match-path test/scratch/V3UnregisteredKeyHash.t.sol fails with 'next call did not revert as expected' on the current code and passes once the constructor rejects an unregistered lane.","severity":"high","snippet":"            subscriptionId == 0 || keyHash == bytes32(0) || callbackGasLimit < MIN_CALLBACK_GAS_LIMIT","title":"Unregistered VRF gas lane accepted at construction; rejected only by requestSeed() after the sale, with no recovery"},{"citation":"resolved","description":"V3 allows exactly one seed request (seedRequestId must be 0, no re-request, no fallback) and requestSeed() is permissionless the moment closeSale() conditions hold. The coordinator accepts a request from an unfunded subscription because billing happens at fulfilment; Chainlink's VRF v2.5 subscription documentation states: 'If your balance is below the minimum, your requests remain pending for up to 24 hours before they expire' and 'After you add sufficient LINK or native tokens to a subscription, pending requests automatically process as long as they have not expired.' The repository assumes the opposite (README: the coordinator 'keeps a request pending while the subscription is unfunded and fulfils it after funding'; test_OnlyOneSeedRequestEvenAfterSevenDays fulfils the request 8 days later). So if anyone calls requestSeed() while the subscription holds no balance in the asset selected by the immutable nativePayment flag (the dry-run hard-codes nativePayment=true, so a LINK-only funded subscription is enough), and the balance is not topped up within 24 hours, the request expires, seedRequestId is non-zero forever, rawFulfillRandomWords never arrives, and all 460 mystery tokens remain Pending with 161,000,000 BMC burned. The sale deadline is immutable, so the subscription must be correctly funded in the right asset at the exact instant the deadline passes, and an unrelated third party can trigger the request. Fix (minimal): before requestRandomWords, call VRF_COORDINATOR.getSubscription(cfg.subscriptionId) (part of the vendored IVRFCoordinatorV2Plus interface) and revert unless the selected asset balance (nativeBalance when nativePayment, else balance) is above a configured minimum; optionally also verify this contract is in the returned consumers list. A fuller fix would allow a fresh request after a fixed delay (e.g. 48h) when seedReceived is still false, which is a design change for the requester to decide. The proof targets the funding precheck.","line":263,"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\";\nimport {VRFV2PlusClient} from \"@chainlink/contracts/src/v0.8/vrf/dev/libraries/VRFV2PlusClient.sol\";\nimport {ERC20} from \"@openzeppelin/contracts/token/ERC20/ERC20.sol\";\nimport {ERC20Burnable} from \"@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.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\n/// @dev Stand-in for the mainnet VRF v2.5 coordinator. Like the real one it accepts a request from\n///      an unfunded subscription (billing happens at fulfilment) and exposes `getSubscription`.\ncontract FundableCoordinator {\n    bytes32 public constant REGISTERED = 0x3fd2fec10d06ee8f65e7f2e95f5c56511359ece3f33960ad8a866ae24a8ff10b;\n    uint256 public nextRequestId = 1000;\n    uint96 public linkBalance;\n    uint96 public nativeBalance;\n    address public consumer;\n\n    function fund(uint96 link, uint96 native) external {\n        linkBalance = link;\n        nativeBalance = native;\n    }\n\n    function s_provingKeys(bytes32 key) external pure returns (bool exists, uint64 maxGas) {\n        return (key == REGISTERED, key == REGISTERED ? uint64(500 gwei) : 0);\n    }\n\n    function getSubscription(uint256)\n        external\n        view\n        returns (uint96 balance, uint96 nativeBal, uint64 reqCount, address subOwner, address[] memory consumers)\n    {\n        consumers = new address[](1);\n        consumers[0] = consumer;\n        return (linkBalance, nativeBalance, 0, address(this), consumers);\n    }\n\n    function requestRandomWords(VRFV2PlusClient.RandomWordsRequest calldata) external returns (uint256 id) {\n        consumer = msg.sender;\n        id = nextRequestId++;\n    }\n}\n\n/// @notice Finding: `requestSeed()` is permissionless and spends the single, unrepeatable VRF\n///         request without checking that the subscription can pay for it. Chainlink VRF v2.5\n///         keeps an unpayable request pending for up to 24 hours and then expires it, so a request\n///         fired while the subscription holds no balance in the configured payment asset strands\n///         every mystery token in Pending forever. The request must not be sent while the\n///         subscription holds nothing in the asset selected by `nativePayment`.\ncontract V3UnfundedSeedRequestTest is Test {\n    ProofBMC bmc;\n    FundableCoordinator 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 FundableCoordinator();\n        pass = new BMCIlluminatiPassCandidateV3(\n            address(this),\n            address(bmc),\n            address(coord),\n            deadline,\n            123,\n            coord.REGISTERED(),\n            500_000,\n            3,\n            true, // pay in native ETH\n            \"bafybeihfihwwpev4gkqjuqawgtptsv3ynzsokclo6odsolvoa65aqaac2u\"\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        pass.mintMystery();\n        vm.stopPrank();\n    }\n\n    /// @dev Fails on the current code: the first `requestSeed()` succeeds although the\n    ///      subscription holds 0 ETH and 0 LINK, and from then on no second request is possible.\n    function test_SeedRequestRefusedWhileSubscriptionCannotPay() public {\n        vm.warp(deadline);\n\n        // Subscription funded only with LINK while the pass is configured for native payment:\n        // the coordinator would never be able to bill this request.\n        coord.fund(10 ether, 0);\n        vm.expectRevert();\n        pass.requestSeed();\n        assertEq(pass.seedRequestId(), 0, \"request must not be consumed while unpayable\");\n\n        // Once the configured payment asset is funded the request goes through.\n        coord.fund(10 ether, 1 ether);\n        uint256 id = pass.requestSeed();\n        assertEq(pass.seedRequestId(), id);\n    }\n}","reproduction":"State: 40 Genesis and >=1 mystery minted, block.timestamp == saleDeadline, nativePayment=true, VRF subscription funded with 10 LINK and 0 ETH (or 0 of both). Anyone calls requestSeed(). Expected: revert, request not consumed, so the subscription can be funded and the request retried. Actual: requestRandomWords succeeds, seedRequestId != 0, a second requestSeed() reverts 'seed already requested'; the coordinator cannot bill the request in ETH, the Chainlink node expires it after 24 hours, seedReceived stays false, finalizeReveals(n) reverts 'seed pending' forever. Proof: forge test --match-path test/scratch/V3UnfundedSeedRequest.t.sol fails ('next call did not revert as expected') on the current code and passes when requestSeed() refuses to send the request while getSubscription() shows zero balance in the configured payment asset.","severity":"high","snippet":"        requestId = VRF_COORDINATOR.requestRandomWords(","title":"requestSeed() fires the single unrepeatable VRF request without checking the subscription can pay; VRF v2.5 expires unpayable requests after 24h, stranding all mystery tokens"},{"citation":"resolved","description":"If the 30-day deadline passes before the Genesis phase completes (totalMinted <= 40), no mystery token exists, yet requestSeed() still closes the sale and sends a paid VRF request (0.25-plus LINK equivalent at mainnet lane prices). finalizeReveals() then loops zero times. Nothing is at risk for holders, but subscription funds are wasted and anyone can trigger it. Fix: require(totalMinted > GENESIS_PHASE_SUPPLY, 'nothing to reveal') in requestSeed().","line":253,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Deploy; 25 addresses call mintGenesis() (totalMinted = 25); warp to saleDeadline; call requestSeed() from any EOA. Expected: revert because no mystery token needs randomness. Actual: SaleClosed(25) and SeedRequested(id) are emitted and the subscription is billed on fulfilment; finalizeReveals(10) returns 0.","severity":"low","snippet":"        if (!saleClosed) closeSale();","title":"requestSeed() spends the only VRF request even when there is nothing to reveal (totalMinted <= 40)"},{"citation":"resolved","description":"rawFulfillRandomWords only stores globalSeed; it finalizes nothing even though 500,000 callback gas is reserved (the GAS_PER_REVEAL / CALLBACK_REVEAL_BATCH constants are unused). Between seed delivery and someone calling finalizeReveals(), the tier of every token is deterministic from globalSeed (keccak256(seed, tokenId) % remaining through the virtual Fisher-Yates) but tokenURI still returns the 'Pending Reveal' JSON and revealStatusOf returns AwaitingRandomness (RevealStatus.RandomnessStored is never written). A buyer who computes the draw can purchase Genesis-destined tokens from sellers who rely on the on-chain metadata. The pending JSON discloses this, and anyone can finalize, which bounds the window, so Low. Fix: finalize a bounded batch inside the callback with the gas already paid for (as the production contract does), set _revealStatus to RandomnessStored for all pending ids when the seed lands (or derive it from seedReceived in revealStatusOf), and remove the dead requestIdOf/tokenIdOfRequest/_randomWord state.","line":376,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"State: 40 Genesis + 460 mystery minted, requestSeed() done, coordinator delivers rawFulfillRandomWords(seedRequestId, [42]). Before any finalizeReveals call: revealStatusOf(41) == AwaitingRandomness (1) and tokenURI(41) contains 'Pending Reveal', although replaying _finalize off-chain with globalSeed=42 fixes token 41's tier. Expected: status RandomnessStored (2) and/or the first batch already revealed in the callback. Actual: metadata says pending while the outcome is public.","severity":"low","snippet":"        return _revealStatus[tokenId];","title":"Outcome is publicly computable after seed delivery while tokenURI/revealStatusOf still report Pending; status never reaches RandomnessStored"},{"citation":"resolved","description":"test_OnlyOneSeedRequestEvenAfterSevenDays fulfils the seed request 8 days after it was sent, which the real network does not do: unpayable requests expire after 24 hours. The custom V3RegisteredCoordinator also never models billing or consumer registration, and the suite never deploys V3 against the vendored official mock lib/chainlink/.../VRFCoordinatorV2_5Mock.sol, which has no s_provingKeys getter, so requestSeed() would revert 'unregistered gas lane' against it (ChainlinkMockIntegration.t.sol covers only the production contract). None of the 80 passing tests therefore exercise the single-request liveness path that the task singles out. Fix: add a V3 integration test on the official mock with a wrapper exposing s_provingKeys, a test that an unfunded or LINK-only subscription makes requestSeed() revert, and remove the 8-day fulfilment assumption.","line":87,"path":"test/BMCIlluminatiPassCandidateV3.t.sol","reproduction":"Run forge test --match-contract BMCIlluminatiPassCandidateV3Test: 9 tests pass, including a fulfilment 8 days after request. Deploy V3 against VRFCoordinatorV2_5Mock (createSubscription, fundSubscription, addConsumer) and call requestSeed() after the deadline: expected a request id; actual revert 'unregistered gas lane' because the mock has no s_provingKeys(bytes32).","severity":"low","snippet":"        vm.warp(block.timestamp + 8 days);","title":"V3 tests model VRF liveness incorrectly and never run against Chainlink's official VRFCoordinatorV2_5Mock"},{"citation":"resolved","description":"Boundary review of the 30-day deadline: mintGenesis/mintMystery stop at block.timestamp >= saleDeadline and closeSale() opens at the same instant, so no block exists where a mint and the seed request are both possible; no off-by-one. MAX_TOTAL_BURN, artwork index mapping (picked 0..36 -> genesis-41..77, 37..459 -> standard 1..423), Fisher-Yates swap at r == remaining-1 and remaining >= 1 inside finalizeReveals were checked with concrete values and hold; modulo bias is < 2^-247. The design consequence worth stating: if fewer than 40 Genesis sell in 30 days, the 460 mystery positions are never offered, and mintMystery reports SoldOut (not a deadline error) after the deadline. Not a defect against the stated design; recorded as a product risk and an error-naming nit.","line":232,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Warp to saleDeadline with totalMinted == 39: mintGenesis() reverts GenesisPhaseOver, mintMystery() reverts SoldOut, closeSale() succeeds; remainingMysteryMints() still returns 460 although none can ever be sold.","severity":"info","snippet":"        if (totalMinted < GENESIS_PHASE_SUPPLY) revert GenesisPhaseNotOver();","title":"Mystery phase opens only after all 40 Genesis sell; deadline boundary is consistent (>=) but partial Genesis sell-out forfeits the whole mystery supply"},{"citation":"resolved","description":"CALLBACK_REVEAL_BATCH, GAS_PER_REVEAL, CALLBACK_GAS_RESERVE, requestIdOf, tokenIdOfRequest, _randomWord (deleted in _finalize but never written), the RandomnessStored and VRFConfigSet events, the GenesisCIDAlreadySet error and the VRFNotConfigured branch (configured is always true) are unreachable or never set in V3, and MysteryMinted always emits requestId 0. forge build --sizes reports BMCIlluminatiPassCandidateV3 at 23,592 bytes runtime, 984 bytes below the 24,576-byte limit, so any fix (including the two High fixes above, roughly 300-400 bytes) must be re-measured. Removing the dead items recovers margin and removes misleading public getters.","line":88,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"forge build --sizes: 'BMCIlluminatiPassCandidateV3 | 23,592 | 25,449 | 984'. pass.requestIdOf(41) == 0 and pass.tokenIdOfRequest(seedRequestId) == 0 after a full sale and fulfilment.","severity":"info","snippet":"    uint256 public constant CALLBACK_REVEAL_BATCH = 4;","title":"Dead state and constants inherited from the per-mint design; runtime size 984 bytes under the EIP-170 limit"},{"citation":"resolved","description":"README and docs/SECURITY-REVIEW.md document the production contract src/BMCIlluminatiPass.sol (per-mint VRF, setGenesisImagesCID, setVRFConfig, callback batching). Candidate V3 has none of these: CID and VRF config are constructor-locked and the Ownable role has no gated function, so the only owner powers are transferOwnership/renounceOwnership. This is a trust assumption, not a vulnerability, but the readiness decision should be made against V3's actual surface, and the docs should clearly separate V3 from the older experimental candidates (Candidate, V2, BMCPassVRFPrototype, BMCPostSaleSeedController) and from the production contract.","line":73,"path":"README.md","reproduction":"grep -n 'function set' src/experimental/BMCIlluminatiPassCandidateV3.sol returns nothing, while README lines 73-74 and 100-104 instruct the owner to call setGenesisImagesCID and setVRFConfig after launch.","severity":"info","snippet":"| `setGenesisImagesCID(cid)` | owner, once | Sets the IPFS directory for Genesis images. | Cannot be changed afterwards; no mint is possible before it is set. |","title":"Documentation describes owner setters V3 does not have; V3 owner role is vestigial"},{"citation":"resolved","description":"Checked against Chainlink's VRF v2.5 supported-networks page for Ethereum mainnet: coordinator 0xD7f86b4b8Cae7D942340FF628F82735b7a20893a, 500 gwei key hash 0x3fd2fec10d06ee8f65e7f2e95f5c56511359ece3f33960ad8a866ae24a8ff10b, max gas limit 2,500,000 (script uses 500,000), minimum confirmations 3 (script uses 3): all match. Not verifiable from this environment: that the Genesis CID bafybeihfihww...aac2u resolves to the 77 PNGs in artwork/genesis (gateway returned HTTP 429), that SUBSCRIPTION_ID exists, is funded in ETH (nativePayment=true), and will list the deployed address as consumer. The dry run asserts only owner, deadline and CID equality; it should also assert s_provingKeys(KEY_HASH).exists and read getSubscription(SUBSCRIPTION_ID) on the fork, and the launch checklist must pin the artwork directory and add the consumer before the sale ends.","line":16,"path":"script/DeployBMCIlluminatiPassV3DryRun.s.sol","reproduction":"curl -I https://ipfs.io/ipfs/bafybeihfihwwpev4gkqjuqawgtptsv3ynzsokclo6odsolvoa65aqaac2u/genesis-01.png returned HTTP 429 from this host; forge script DeployBMCIlluminatiPassV3DryRun requires a mainnet fork (not available here) and performs no lane or subscription assertion.","severity":"info","snippet":"    string internal constant GENESIS_CID =","title":"Deployment configuration: Chainlink constants verified; IPFS CID and subscription state not verifiable offline; dry run does not check lane or funding"}],"hash":"5356913e3987553d420c2dcc3f2a45cdf6facf24634a7d39f9d811da63111cc6","nodeId":"8cffef43-ce0f-4fc9-9418-97c860a43826","outcome":"completed","summary":"The audit is complete. The findings are written to `.imd-findings.json` in the repository root, with two Foundry proofs embedded and left under `test/scratch/`. No source file was modified.\n\n**Verdict: Candidate V3 is not Mainnet-ready.** The contract itself is labelled \"NOT FOR DEPLOYMENT\", and the review found two High-severity liveness defects in its single-request VRF design, both of which turn a deployment or funding mistake into a permanent reveal stall after all BMC has been burned.\n\n**Findings, by severity**\n\n- **High, line 193.** The constructor accepts any non-zero VRF key hash. The lane is only checked by `requestSeed()` after the sale closes, and V3 has no setter. A wrong lane at deployment means 30 days of normal minting, then every seed request reverts forever and all 460 mystery tokens stay Pending. Proof test fails on the current code and passes once the constructor rejects an unregistered lane.\n- **High, line 263.** `requestSeed()` is permissionless and sends the one unrepeatable VRF request without checking the subscription can pay. Chainlink's documentation states unpayable requests expire after 24 hours. The repository assumes they wait indefinitely, and its own test fulfils 8 days later. A subscription funded in LINK while the script hard-codes native payment, or simply empty at the deadline, strands the whole collection. Proof test fails now and passes with a `getSubscription` funding precheck.\n- **Low.** The seed request is spent even when no mystery token exists. Outcomes are publicly computable after seed delivery while metadata still says Pending, and the status never reaches RandomnessStored. The V3 tests never run against Chainlink's official mock, which lacks the getter the contract depends on.\n- **Info.** Deadline boundaries, burn totals, artwork index mapping and the Fisher-Yates draw were checked with concrete values and hold. Dead state and constants remain from the per-mint design, and the runtime is 984 bytes under the size limit. The README documents owner setters V3 does not have. Chainlink deployment constants match the official mainnet values. The IPFS CID and subscription state could not be verified offline.\n\n**Test verification.** All 80 non-fork tests pass locally. The mainnet fork test fails only because no RPC is available here.\n\n**What this review did not cover.** The live BMC token's `burnFrom` behaviour, the IPFS directory contents, and the subscription's consumer list were not reachable from this environment and remain open items for the deployer.","treeHash":null,"usage":{"cachedInputTokens":1265320,"inputTokens":354,"model":"claude-fable-5-1","outputTokens":42305,"runtime":"claude","turns":33,"wallClockMs":622512}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"f2101c240e0b52a9","findings":[{"citation":"resolved","description":"requestSeed() may succeed exactly once (seedRequestId is set and never cleared) and rawFulfillRandomWords only accepts that id. Chainlink VRF v2.5 does not check the subscription balance when the request is made, but its subscription docs state: 'If your balance is below the minimum, your requests remain pending for up to 24 hours before they expire.' An expired request is never delivered, and the real coordinator similarly consumes the request commitment if the callback ever fails. V3 has no retry, no reconfiguration and no fallback, so after one lost request seedReceived stays false forever, finalizeReveals() reverts 'seed pending' forever, and all mystery tokens (up to 460, bought with up to 161,000,000 BMC that was destroyed via burnFrom) stay 'Pending Reveal' permanently. requestSeed() is permissionless, so an unprivileged caller can fire the only request the moment the deadline passes, before the operator has funded the subscription. This is not hypothetical for the configured deployment: on mainnet (read 2026-10-12) subscription 27852459...562985 on coordinator 0xD7f8...893a holds 0 LINK and 0.02 ETH native with nativePayment=true, while the 500 gwei lane with a 500,000 callback gas limit has a minimum (Max Cost) on the order of 0.4 ETH. The production BMCIlluminatiPass and Candidate V2 both have retry/per-token paths; V3 removed them and documents the risk in a comment instead of handling it. Fix (preserves the single-live-request property): keep seedRequestId, but allow requestSeed() again when !seedReceived and block.timestamp >= seedRequestedAt + RETRY_DELAY (RETRY_DELAY >= 48h, i.e. beyond Chainlink's 24h expiry); record seedRequestedAt; keep rawFulfillRandomWords accepting only the latest seedRequestId so a late delivery of a superseded request is rejected (the coordinator then just marks it fulfilled). Additionally, pull the request off the unprivileged path by letting only the owner call requestSeed during the first RETRY_DELAY after close (anyone afterwards), so a griefer cannot force the request while the subscription is underfunded. Operationally, require the subscription to hold at least the lane's Max Cost before the deadline.","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 BMC stand-in: 18 decimals, OpenZeppelin ERC20Burnable.burnFrom.\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 IRawFulfill {\n    function rawFulfillRandomWords(uint256 requestId, uint256[] calldata randomWords) external;\n}\n\n/// @dev Coordinator stand-in modelling Chainlink VRF v2.5 behaviour for an underfunded subscription:\n///      the request is accepted on-chain (no balance check at request time) but is never fulfilled.\n///      Per Chainlink docs: \"If your balance is below the minimum, your requests remain pending for up\n///      to 24 hours before they expire.\" After expiry the only way to obtain randomness is a NEW request.\ncontract ProofCoordinator {\n    bytes32 public constant VALID_KEY = keccak256(\"registered-key\");\n    uint256 public nextRequestId = 1000;\n    mapping(uint256 => address) public consumerOf;\n    mapping(uint256 => uint32) public gasOf;\n    mapping(uint256 => bool) public expired;\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 requestRandomWords(VRFV2PlusClient.RandomWordsRequest calldata req) external returns (uint256 id) {\n        id = nextRequestId++;\n        consumerOf[id] = msg.sender;\n        gasOf[id] = req.callbackGasLimit;\n    }\n\n    /// @dev Off-chain expiry: the node drops the request; it will never be delivered.\n    function expire(uint256 id) external {\n        expired[id] = true;\n    }\n\n    function fulfill(uint256 id, uint256 word) external returns (bool ok) {\n        if (expired[id]) return false; // dropped off-chain: never delivered\n        uint256[] memory words = new uint256[](1);\n        words[0] = word;\n        (ok,) = consumerOf[id].call{gas: gasOf[id]}(abi.encodeCall(IRawFulfill.rawFulfillRandomWords, (id, words)));\n    }\n}\n\n/// @notice Fails on the current V3: once the sole seed request expires unfulfilled, no new request can\n///         ever be made, so every mystery token stays pending forever although its BMC is burned.\n///         Passes once the contract allows a fresh seed request after a long unfulfilled period.\ncontract V3SeedRequestExpiryStallTest is Test {\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,\n            \"bafybeihfihwwpev4gkqjuqawgtptsv3ynzsokclo6odsolvoa65aqaac2u\"\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\n    function test_SeedCanBeRerequestedAfterUnfulfilledRequestExpires() public {\n        uint256 burnedBefore = pass.totalBurned();\n        assertEq(burnedBefore, 40 * 1_000_000 ether + 10 * 350_000 ether);\n\n        // Deadline passes. The owner (this test contract) fires the single seed request while the\n        // subscription is still below Chainlink's minimum balance. (On the current code any address\n        // can do this; the owner is used here so the test also passes if the fix owner-gates it.)\n        vm.warp(deadline);\n        uint256 first = pass.requestSeed();\n        assertEq(pass.seedRequestId(), first);\n\n        // Subscription stays below the minimum balance for >24h: Chainlink drops the request.\n        vm.warp(block.timestamp + 2 days);\n        coord.expire(first);\n        assertFalse(coord.fulfill(first, 42), \"expired request must never deliver\");\n        assertFalse(pass.seedReceived());\n\n        // 30 more days pass with no fulfilment. The protocol must be able to recover: a new request.\n        vm.warp(block.timestamp + 30 days);\n        uint256 second = pass.requestSeed(); // CURRENT CODE: reverts \"seed already requested\"\n        assertTrue(second != first, \"a fresh request id is required\");\n\n        // The fresh request is delivered and reveals proceed.\n        assertTrue(coord.fulfill(second, 42));\n        assertTrue(pass.seedReceived());\n        assertEq(pass.finalizeReveals(10), 10);\n        assertEq(pass.pendingReveals(), 0);\n        assertEq(pass.totalBurned(), burnedBefore, \"burns were irreversible throughout\");\n    }\n}","reproduction":"State: 40 Genesis + N>=1 mystery tokens minted; block.timestamp >= saleDeadline; subscription balance below the lane minimum (mainnet value today: 0.02 ETH). Step 1: any address calls requestSeed() -> coordinator returns requestId R, seedRequestId=R. Step 2: no funding within 24h -> Chainlink drops R off-chain; rawFulfillRandomWords(R, ...) is never called. Step 3 (any later time): requestSeed() reverts 'seed already requested'; finalizeReveals(x) reverts 'seed pending'; tierOf(41..40+N) == Pending; totalBurned unchanged. Expected: a way to obtain a fresh seed after a request is demonstrably lost. Actual: permanent stall. Proof test test/scratch/V3SeedRequestExpiryStall.t.sol fails on current code with 'seed already requested'.","severity":"high","snippet":"        require(seedRequestId == 0, \"seed already requested\");","title":"Single VRF seed request with no re-request path: an unfulfilled (expired) request permanently stalls every mystery reveal while all BMC burns are irreversible"},{"citation":"resolved","description":"The constructor only range-checks the VRF parameters; it does not verify that keyHash is registered on the coordinator (requestSeed does, but only later) nor that subscriptionId exists, and there is no setter at all (Ownable is inherited but no function is owner-gated). Any pre-request failure of that fixed configuration is therefore unrecoverable: (a) the subscription owner (mainnet: EOA 0x83027AD9d0D5374335086c539dADD1db983441B0, distinct from the pass owner, currently 0 consumers) can cancelSubscription() at any time before requestSeed(); v2.5 subscription ids are hash-derived so a replacement subscription can never match the immutable id and requestRandomWords reverts InvalidSubscription forever; (b) Chainlink deregistering the lane makes the 'unregistered gas lane' guard revert forever; (c) a typo in the 77-digit subscription id or key hash at deployment passes the constructor and the fork dry-run (which never exercises requestSeed) and is discovered only after up to 201,000,000 BMC has been burned. Trust assumption to document: the Chainlink subscription owner must keep the subscription alive and consumer-registered until the seed is delivered. Fix: (1) in the constructor staticcall s_provingKeys(keyHash) and getSubscription(subscriptionId) on the coordinator and revert if the key is not registered or the subscription does not exist (fail-fast, no design change); (2) add an owner-only setVRFConfig that is callable only while seedRequestId == 0 (no live request), so the single-request fairness property is untouched but a dead configuration can be replaced; emit the already-declared but never-emitted VRFConfigSet event.","line":204,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"State: 40 Genesis + 5 mystery minted (41.75M BMC burned). Subscription owner calls cancelSubscription(subId, to) on the coordinator before the deadline. At deadline: requestSeed() -> VRF_COORDINATOR.requestRandomWords reverts InvalidSubscription; 365 days later identical result; pendingReveals()==5 forever. Expected: owner can point the contract at a valid subscription/lane while no request is outstanding, or the constructor rejects an invalid configuration. Actual: no recovery path exists. Verified with scratch test test/scratch/V3Leads.t.sol::test_CancelledSubscriptionStallsForever (passes, i.e. demonstrates the stall).","severity":"medium","snippet":"        vrfConfig = VRFConfig(subscriptionId, keyHash, callbackGasLimit, requestConfirmations, nativePayment, true);","title":"VRF subscription id and key hash are fixed at construction with no validation and no reconfiguration: a cancelled subscription or deregistered lane before requestSeed() bricks reveals permanently"},{"citation":"resolved","description":"rawFulfillRandomWords only writes seedReceived/globalSeed. Unlike BMCIlluminatiPass.rawFulfillRandomWords (which immediately runs a gas-bounded try finalizeReveals(batch)), V3 leaves all 460 reveals to a later permissionless call. Between fulfilment and finalizeReveals the seed is public (globalSeed getter and the SeedReceived tx), and the draw is a pure function of (globalSeed, tokenId), so anyone can compute which pending tokens become Genesis (37/460 of them, 1,000,000-BMC tier) and buy them on secondary markets from holders who see only 'Pending Reveal' metadata. The pending JSON discloses this, and finalization is permissionless, but the contract itself creates the window and the constants CALLBACK_REVEAL_BATCH, GAS_PER_REVEAL and CALLBACK_GAS_RESERVE declared for the mitigation are unused in V3. Fix: reinstate the production pattern (after storing the seed, compute batch from gasleft() and call try this.finalizeReveals(batch) {} catch {}) so the first tokens are revealed atomically with the seed, and keep the 500k callback gas limit which covers ~4 reveals.","line":308,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"State: 500 minted, requestSeed() done, coordinator delivers word 42. Observed: revealedCount()==0, tierOf(41)==Pending. Off-chain: replay _finalize for tokens 41..60 with r = keccak256(abi.encode(42, tokenId)) % remaining over a virtual 460-slot Fisher-Yates; predicted Genesis flags match tierOf() exactly after finalizeReveals(20). Expected: at least the first batch revealed in the same transaction as the seed. Actual: zero reveals until someone calls finalizeReveals. Verified by scratch test test/scratch/V3Leads.t.sol::test_OutcomesPredictableBeforeFinalize.","severity":"low","snippet":"        globalSeed = randomWords[0];","title":"Seed is stored but nothing is finalized in the callback (gas-aware batch dropped from production design), leaving an open window in which every outcome is computable while tokens still display 'Pendin"},{"citation":"resolved","description":"mintMystery() is gated on totalMinted >= 40, so if the 30-day deadline passes with fewer than 40 Genesis mints the mystery phase never opens and pendingReveals()==0. requestSeed() nevertheless closes the sale and submits a real VRF request (charged to the subscription at mainnet prices, 500 gwei lane) that no token will ever use; finalizeReveals() then loops zero times. Because requestSeed is permissionless, any address can trigger this spend. Fix: require(totalMinted > GENESIS_PHASE_SUPPLY, 'nothing to reveal') in requestSeed(). Related design note for the readiness verdict: a 30-day window that must first absorb 40 x 1,000,000 BMC (4% of the 989M supply) before any 350,000-BMC mystery mint is possible is a sale-structure risk the team should consciously accept.","line":252,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"State: 39 Genesis minted, block.timestamp == saleDeadline. mintMystery() reverts GenesisPhaseNotOver(); pendingReveals()==0. Call requestSeed(): coordinator request counter increments (subscription charged on fulfilment); after fulfilment finalizeReveals(100) returns 0. Expected: revert when there is nothing to reveal. Actual: a paid request is made. Verified by scratch test test/scratch/V3Leads.t.sol::test_SeedRequestedWithNothingToReveal.","severity":"low","snippet":"    function requestSeed() external returns (uint256 requestId) {","title":"requestSeed() spends a paid VRF request even when no mystery token exists (deadline reached with fewer than 40 Genesis sold)"},{"citation":"resolved","description":"Live reads on 2026-10-12 from coordinator 0xD7f86b4b8Cae7D942340FF628F82735b7a20893a: s_provingKeys(0x3fd2fec1...ff10b) = (true, 500 gwei) so the lane is valid; getSubscription(27852459...562985) = balance 0 LINK, nativeBalance 0.02 ETH, reqCount 0, owner 0x83027AD9d0D5374335086c539dADD1db983441B0 (EOA holding ~0.048 ETH), consumers []. The script selects nativePayment=true, so only the ETH balance counts; with callbackGasLimit 500,000 and a 500 gwei ceiling plus the 24% native premium the lane's minimum balance is on the order of 0.4 ETH, far above 0.02 ETH. The pass contract is not a consumer, so requestSeed() would revert InvalidConsumer until the subscription owner adds it after deployment. DeployBMCIlluminatiPassV3DryRun only constructs the contract and checks owner/deadline/CID; it never calls requestSeed, so the CI 'deployment simulation' cannot catch any of this. The Genesis CID bafybeihfihwwpev4gkqjuqawgtptsv3ynzsokclo6odsolvoa65aqaac2u does resolve (genesis-01.png 2,739,752 bytes and genesis-77.png served by gateway.pinata.cloud; genesis-01.png size matches artwork/genesis/genesis-01.png), but ipfs.io and dweb.link rate-limited, so pinning redundancy is unverified. Fix: extend the dry run to (1) prank the subscription owner and addConsumer(subId, address(pass)), (2) warp past the deadline and call requestSeed() on the fork, (3) assert getSubscription native balance >= an explicit minimum; and before any real deployment add the consumer and fund the subscription to at least the lane's Max Cost (or choose the 200 gwei lane 0x8077df514608a09f83e4e8d300645594e5d7234665448ba83f51a50f842bd3d9 to lower it).","line":13,"path":"script/DeployBMCIlluminatiPassV3DryRun.s.sol","reproduction":"cast call 0xD7f86b4b8Cae7D942340FF628F82735b7a20893a 'getSubscription(uint256)' 27852459484169864437638942739291618120641810244677084392617474192083559562985 -> (0, 20000000000000000, 0, 0x83027AD9d0D5374335086c539dADD1db983441B0, []). Deploy V3 with the script constants, add nothing else, warp to deadline, call requestSeed(): reverts InvalidConsumer(subId, pass). After addConsumer: request accepted but subscription below minimum -> pending, expires in 24h (see the high finding). Expected: dry-run proves the seed can be requested and paid. Actual: dry-run only proves the constructor runs.","severity":"low","snippet":"        27852459484169864437638942739291618120641810244677084392617474192083559562985;","title":"Deployment configuration: hardcoded mainnet subscription has zero consumers and 0.02 ETH native balance against a 500 gwei lane with nativePayment=true; dry-run never exercises the VRF path"},{"citation":"resolved","description":"Never written or emitted in V3: requestIdOf, tokenIdOfRequest, _randomWord (only deleted), RevealStatus.RandomnessStored, events RandomnessStored and VRFConfigSet (the config IS set in the constructor but no event is emitted, asymmetric with GenesisImagesCIDSet), errors GenesisCIDAlreadySet/VRFNotConfigured (configured is always true)/GenesisCIDNotSet (genesisCIDLocked is always true), constants CALLBACK_REVEAL_BATCH/GAS_PER_REVEAL/CALLBACK_GAS_RESERVE, and MysteryMinted always emits requestId 0. Ownable is inherited but no function is onlyOwner, so owner(), transferOwnership and renounceOwnership have no effect; the README's privileged-powers table (setGenesisImagesCID, setVRFConfig) describes the production contract, not V3. mintMystery reverts with SoldOut() when the deadline has passed, which is misleading for integrators. Impact is readability and integrator confusion (indexers reading requestIdOf/tokenIdOfRequest get zeros), not funds. Fix: delete the unused members, emit VRFConfigSet in the constructor, add a SaleEnded error for the deadline branch, and either drop Ownable or document that the owner has no powers in V3.","line":88,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"After mintMystery(): requestIdOf(41)==0, tokenIdOfRequest(anything)==0, MysteryMinted emitted with requestId 0; revealStatusOf(41) goes AwaitingRandomness -> Revealed, RandomnessStored is never observed. grep onlyOwner src/experimental/BMCIlluminatiPassCandidateV3.sol returns nothing. At block.timestamp >= saleDeadline with 100 tokens minted, mintMystery() reverts SoldOut() although 400 remain.","severity":"info","snippet":"    uint256 public constant CALLBACK_REVEAL_BATCH = 4;","title":"Dead state, unused constants/events/errors and an inert Ownable inherited from earlier candidates make V3's surface misleading"},{"citation":"resolved","description":"All 9 V3 tests use V3RegisteredCoordinator (a hand-written mock). The official VRFCoordinatorV2_5Mock does not expose s_provingKeys, so V3.requestSeed() reverts 'unregistered gas lane' against it; every suite that uses the official mock (ChainlinkMockIntegration, VRFRecovery*, VRFSafetyProperties) targets the production BMCIlluminatiPass, not V3. Untested for V3: a callback delivered with exactly callbackGasLimit via the official mock, an unfulfilled/expired request (the single-request liveness assumption), partial mystery sell-out (fairness of the 460-slot pool when fewer tokens exist), the deadline passing with fewer than 40 Genesis, requestSeed before consumer registration, and the 500-token finalizeReveals gas profile (a full 460-token finalize in one call needs roughly 35-40M gas, above the block limit, so maxCount batching is required but never asserted). The '80 passing tests' and the fork dry-run therefore give little assurance about V3's VRF liveness, which is the property the design hinges on. Fix: add a V3 suite against VRFCoordinatorV2_5Mock using vm.mockCall for s_provingKeys, plus the scratch cases listed above.","line":9,"path":"test/BMCIlluminatiPassCandidateV3.t.sol","reproduction":"forge test --match-contract BMCIlluminatiPassCandidateV3Test runs 9 tests, none importing VRFCoordinatorV2_5Mock. Deploy V3 with address(new VRFCoordinatorV2_5Mock(...)) and call requestSeed() after the deadline: staticcall to s_provingKeys fails -> 'unregistered gas lane'.","severity":"info","snippet":"contract V3RegisteredCoordinator is MockVRFCoordinator {","title":"Test coverage gap: V3 is only exercised against a custom mock; the official Chainlink VRFCoordinatorV2_5Mock, callback-gas, expiry, partial sell-out and deadline-with-<40-Genesis paths are untested"}],"hash":"9544a6b0ff5c8dc658fdbe050a7f6a2dd616c73f90dd8d8ad9b0b422214e8dbc","nodeId":"6e080122-b2a0-438e-8305-392a4e83469b","outcome":"completed","summary":"The audit is complete. The findings file is written and verified, the proof test fails on the current code for the stated reason, and no repository source files were modified.\n\n## Verdict\n\n**Candidate V3 is not ready for Ethereum Mainnet.** The contract's core logic (burn accounting, sequential Genesis mints, in-order virtual Fisher-Yates draw, coordinator-only callback, on-chain metadata) is sound and I found no permission bypass, no reentrancy path and no way to bias or choose a rarity outcome. The blocking problem is liveness: V3 stakes up to 161,000,000 irreversibly burned BMC on exactly one Chainlink VRF request ever succeeding, and both the contract and the planned deployment configuration make a lost request realistic.\n\n## Findings written to `.imd-findings.json` (7)\n\n- **High, with proof test.** `requestSeed()` can succeed only once and nothing can replace a request that is never delivered. Chainlink's own v2.5 docs say an underfunded request \"remain[s] pending for up to 24 hours before [it] expire[s]\". Once that happens every mystery token stays \"Pending Reveal\" forever. The function is permissionless, so anyone can fire the single request at the deadline before the operator funds the subscription. Proof: `test/scratch/V3SeedRequestExpiryStall.t.sol`, which fails now with \"seed already requested\".\n- **Medium.** The subscription id and key hash are immutable, unvalidated at construction, and there is no reconfiguration path. The subscription owner (a separate EOA) cancelling the subscription before the request, a deregistered lane, or a typo in the 77-digit id all stall reveals permanently. Reproduced in a scratch test.\n- **Low.** The callback stores the seed but finalizes nothing (the production contract's gas-aware batch was dropped, its constants left unused), so all 460 outcomes are computable from the public seed while tokens still show \"Pending\". Reproduced by predicting tokens 41 to 60 off-chain.\n- **Low.** `requestSeed()` spends a paid VRF request even when the deadline passed with fewer than 40 Genesis sold and no mystery token exists.\n- **Low, deployment config.** Live mainnet reads today: the hardcoded subscription has zero consumers, 0 LINK and 0.02 ETH native, with `nativePayment=true` on the 500 gwei lane whose minimum balance is roughly 0.4 ETH. The dry-run script never exercises the VRF path, so CI cannot catch this. The key hash is valid and the Genesis CID resolves on Pinata with a byte-exact match for genesis-01.png.\n- **Info.** Dead state and events inherited from earlier candidates, an `Ownable` with no owner-gated function, and a README that documents the production contract's powers rather than V3's.\n- **Info.** V3 is tested only against a hand-written mock; it cannot run against the official Chainlink mock (no `s_provingKeys`), and expiry, partial sell-out, callback gas and deadline-with-<40-Genesis paths are untested.\n\n## Verification performed\n\n- Existing suite: all 80 tests pass with forge 1.8.3 and solc 0.8.26; the mainnet fork suite was excluded as in CI.\n- Live chain reads against the mainnet coordinator and BMC token through a public RPC, plus Chainlink documentation and IPFS gateway checks.\n- Three scratch tests substantiating the medium and low findings all pass as written (they demonstrate the defects).\n\n## Not covered\n\nSlither and Mythril were not run (not provided). I could not test `burnFrom` against the live BMC token or exercise the real coordinator's fulfilment path without a funded account. The 30-day window requiring 40,000,000 BMC of Genesis sales before any mystery mint is a sale-structure risk, noted inside the low finding rather than reported separately.","treeHash":null,"usage":{"cachedInputTokens":1366942,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":43428,"runtime":"claude","turns":42,"wallClockMs":712551}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"759c614fdc84ff66","findings":[{"citation":"resolved","description":"Candidate V3 (not the older BMCIlluminatiPass / Candidate / V2 contracts, which request per-mint) issues exactly one VRF request for the whole collection. requestSeed() is callable by anyone as soon as the sale closes, and once seedRequestId != 0 it can never be called again. No timeout, re-request, owner recovery or fallback exists (rawFulfillRandomWords only accepts that one id, and VRF_COORDINATOR, subscriptionId and keyHash are immutable). Chainlink VRF v2.5's requestRandomWords does not check the subscription balance. A request made while the subscription cannot pay is held off-chain and dropped after Chainlink's 24h pending window, and nothing on-chain re-triggers it. A coordinator or oracle outage longer than that window does the same. Every mystery token (ids 41..totalMinted, up to 460 tokens and 161,000,000 BMC already burned) then stays Tier.Pending forever: tierOf()==Pending, artworkIdOf()==0, and tokenURI() returns the 'Pending Reveal' JSON. Since the request is permissionless, anyone can fire it at the worst moment (for example the block the deadline passes, before the operator tops up the subscription). The deployment configuration makes this realistic. On mainnet at block 26177084 the configured subscription 2785245948...9562985 holds 0.02 ETH native and 0 LINK, with no consumers. The dry-run config is nativePayment=true with callbackGasLimit=500,000 on key hash 0x3fd2...f10b, whose s_provingKeys maxGas is 500 gwei; Chainlink's minimum-balance rule prices at that maximum, so roughly 0.3 ETH would be needed. The code comment acknowledges the stall but does not mitigate it. For a mainnet launch with irreversible burns it is a permanent loss of the purchased asset's core property.","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 burnable 18-decimal token with OpenZeppelin ERC20Burnable burnFrom semantics.\ncontract StallBMC {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    uint256 public totalSupply;\n\n    function decimals() external pure returns (uint8) {\n        return 18;\n    }\n\n    function mint(address to, uint256 amount) external {\n        balanceOf[to] += amount;\n        totalSupply += 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        balanceOf[account] -= amount;\n        totalSupply -= amount;\n    }\n}\n\n/// @dev VRF v2.5 coordinator stand-in. Like the real coordinator, `requestRandomWords` does not\n///      check the subscription balance; a request whose subscription is under-funded is simply\n///      never fulfilled by the oracle network (Chainlink drops pending requests after 24 hours).\ncontract StallCoordinator {\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 = 1;\n    mapping(uint256 => address) public consumerOf;\n\n    function s_provingKeys(bytes32) external pure returns (bool exists, uint64 maxGas) {\n        return (true, uint64(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 id, uint256 word) external {\n        uint256[] memory words = new uint256[](1);\n        words[0] = word;\n        BMCIlluminatiPassCandidateV3(consumerOf[id]).rawFulfillRandomWords(id, words);\n    }\n}\n\ncontract V3SeedStallTest is Test {\n    StallBMC bmc;\n    StallCoordinator 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 StallBMC();\n        coord = new StallCoordinator();\n        pass = new BMCIlluminatiPassCandidateV3(\n            address(this), address(bmc), address(coord), deadline, 1, 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\n    /// The only seed request is never answered (e.g. requestSeed was called by anyone while the\n    /// subscription was under-funded and the 24h oracle window lapsed). 3,500,000 BMC of mystery\n    /// burns are already irreversible. A fixed contract must let a fresh request be issued after a\n    /// timeout so the pending NFTs can still be revealed.\n    function test_UnansweredSeedRequestCanBeRetriedAfterTimeout() public {\n        vm.warp(deadline);\n        uint256 first = pass.requestSeed();\n        assertEq(pass.seedRequestId(), first);\n\n        // Oracle never fulfils `first`. Wait well past Chainlink's 24h pending window.\n        vm.warp(block.timestamp + 7 days);\n\n        // Currently reverts with \"seed already requested\": the reveal is stalled forever.\n        uint256 second = pass.requestSeed();\n        assertTrue(second != first, \"a fresh request id is expected\");\n\n        coord.fulfill(second, 42);\n        assertTrue(pass.seedReceived());\n        assertEq(pass.finalizeReveals(10), 10);\n        for (uint256 id = 41; id <= 50; ++id) {\n            assertTrue(pass.tierOf(id) != BMCIlluminatiPassCandidateV3.Tier.Pending);\n        }\n    }\n}","reproduction":"Run test/scratch proof (forge test --match-path test/scratch/V3SeedStall.t.sol). Mint the 40 Genesis tokens, then 10 mystery tokens (3,500,000 BMC burned). vm.warp(saleDeadline), call requestSeed() and get id 1. The oracle never fulfils it (an under-funded subscription, or an outage longer than 24h). vm.warp(+7 days), then call requestSeed() again. Expected: a fresh request is issued, fulfilment works and finalizeReveals reveals ids 41..50. Actual: it reverts with 'seed already requested', there is no other path to a seed, finalizeReveals reverts with 'seed pending' forever, and tokens 41..50 stay Tier.Pending. The existing test test_OnlyOneSeedRequestEvenAfterSevenDays asserts this stall as intended behaviour. Fix: store seedRequestedAt and allow requestSeed() again once !seedReceived && block.timestamp >= seedRequestedAt + REREQUEST_DELAY (e.g. 24h, matching Chainlink's pending expiry), overwriting seedRequestId so a late answer to the stale id is rejected by the existing requestId check. This is safe for fairness because minting is closed before any request, so no participant can act on a request's outcome. Optionally have requestSeed() read VRF_COORDINATOR.getSubscription(subId) and require a native/LINK balance at least an estimated max fulfilment cost, so third parties cannot trigger the request against an unfunded subscription. Also lower callbackGasLimit (the callback is about 3 SSTOREs, under 100k gas) to cut the required minimum balance. Verified: with the 24h re-request patch applied in a scratch copy, the proof passes.","severity":"high","snippet":"        require(seedRequestId == 0, \"seed already requested\");","title":"Single, permissionless, non-retryable VRF seed request: an unanswered request permanently freezes all 460 mystery reveals after irreversible BMC burns"},{"citation":"resolved","description":"The subscription id, key hash and coordinator are fixed in the constructor, and Candidate V3 has no setter, no re-request and no fallback. The subscription is not created or owned by the contract. On mainnet, subscription 2785245948...9562985 is owned by EOA 0x83027AD9d0D5374335086c539dADD1db983441B0 and currently has no consumers. That account alone decides whether requestSeed() can ever succeed: it must call addConsumer(subId, pass) after deployment, and it can later call removeConsumer, cancelSubscription (allowed while no request is pending, i.e. during the whole 30-day sale), or transferSubscription. VRFCoordinatorV2_5.requestRandomWords reverts with InvalidConsumer / InvalidSubscription in those states, and immutable config means the pass owner cannot point the contract at a new subscription. So a third party outside the contract's stated trust model (the NatSpec says the owner 'cannot ... upgrade' and that VRF config is fixed) holds a permanent veto over reveals of NFTs whose BMC was burned irreversibly. This differs from the single-request issue: it bricks requestSeed before any request exists.","line":204,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Deploy with the dry-run config (subscriptionId 27852459484169864437638942739291618120641810244677084392617474192083559562985). Do one of the following during the sale: (a) the subscription owner 0x8302...41B0 never calls addConsumer(subId, pass), for example through a lost key or operator error; (b) it calls removeConsumer(subId, pass); or (c) it calls cancelSubscription(subId, to). Mystery mints still succeed and burn 350,000 BMC each, because minting never touches the coordinator. After saleDeadline, requestSeed() reverts inside VRF_COORDINATOR.requestRandomWords (InvalidConsumer or InvalidSubscription) on every call, forever. Expected: an operator-recoverable path to randomness. Actual: tokens 41..totalMinted stay Tier.Pending permanently. Fix (preserving 'no owner power over outcomes'): have the constructor call VRF_COORDINATOR.createSubscription() and addConsumer(subId, address(this)) so the contract owns its own subscription that nobody can cancel or detach (anyone can still fund it with fundSubscriptionWithNative / LINK transferAndCall). Alternatively, allow the owner to replace subscriptionId/keyHash only while !seedReceived, emitting VRFConfigSet. Since the sale is closed before any request, changing the subscription cannot bias outcomes. Also add a pre-launch check in the deploy flow that getSubscription(subId).consumers contains the pass address before the sale opens.","severity":"medium","snippet":"        vrfConfig = VRFConfig(subscriptionId, keyHash, callbackGasLimit, requestConfirmations, nativePayment, true);","title":"Reveal liveness depends on an externally owned VRF subscription that the contract cannot replace; consumer removal, cancellation or a lost subscription-owner key bricks requestSeed forever"},{"citation":"resolved","description":"rawFulfillRandomWords stores one public globalSeed and reveals nothing. Each token's outcome is a pure function of (globalSeed, tokenId, in-order Fisher-Yates state): r = keccak256(abi.encode(seed, tokenId)) % (460 - revealedCount). tierOf/artworkIdOf/tokenURI stay Pending until someone separately calls finalizeReveals. Anyone watching the coordinator's fulfilment transaction in the public mempool, or reading globalSeed afterwards, therefore knows which pending tokens are Genesis (worth 1M-BMC-class) before holders or marketplaces do. They can buy Genesis-bound pending tokens listed at the pending floor, front-run in the same block as fulfilment, and sell Standard-bound ones to buyers relying on the pending metadata. The pending JSON admits 'rarity can be calculated', but the contract leaves the window open indefinitely: finalization costs gas and nobody is obliged to call it. revealStatusOf also keeps returning AwaitingRandomness after the seed exists, which hides the window from integrators.","line":308,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Mint the 40 Genesis tokens and 1 mystery token (id 41). Search seeds: seed=5 gives keccak256(abi.encode(5,41)) % 460 < 37, i.e. Genesis. After the deadline call requestSeed(), then have the coordinator fulfil with 5. Now tierOf(41) == Pending (0), revealStatusOf(41) == AwaitingRandomness, and tokenURI(41) is the 'Pending Reveal' JSON. Yet globalSeed()==5 already determines the outcome. After finalizeReveals(1), tierOf(41)==Genesis with artwork genesis-59.png, exactly as predicted (verified in a scratch Foundry test). Expected: metadata and on-chain views reflect the outcome as soon as it is determined. Actual: an information window of unbounded length. Fix: make the views derive the outcome the moment seedReceived is true. One option is to compute each token's draw lazily in tierOf/artworkIdOf/tokenURI: tokens are finalized strictly in id order, so a view can replay the virtual shuffle in memory, which costs a few hundred thousand gas in an eth_call. Another is to finalize as many tokens as the callback gas allows inside rawFulfillRandomWords and set revealStatus to RandomnessStored for the rest. In addition, emit an ERC-4906 BatchMetadataUpdate(41, totalMinted) on seed receipt so marketplaces refresh immediately.","severity":"low","snippet":"        globalSeed = randomWords[0];","title":"Every mystery outcome is public from the moment the seed is fulfilled (even from the pending fulfilment tx in the mempool) while marketplaces still show 'Pending Reveal': uninformed holders/listings c"},{"citation":"resolved","description":"The sale closes permanently at saleDeadline (closeSale/mint guards use block.timestamp >= saleDeadline), and mystery minting is gated on all 40 Genesis tokens selling first. Draws without replacement only happen for tokens actually sold, so after a partial sale most of the 37 mystery Genesis artworks and 423 Standard artworks never exist. The on-chain metadata is fixed and still tells buyers each piece 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). The legendary text likewise says 'the rarest piece of the 500-piece collection'. Fixed metadata therefore misstates rarity and supply, the property the collection's value rests on. remainingMysteryGenesis()/remainingMysteryStandard() also keep reporting undrawn outcomes as 'still inside the random pool' after the sale is over.","line":459,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Mint the 40 Genesis tokens and 1 mystery token, warp to saleDeadline, call requestSeed(), fulfil with seed 5, then finalizeReveals(1). Final supply is 41 tokens (totalMinted()==41, of which 41 are Genesis), but tokenURI(41) decodes to '... One of 77 Genesis pieces in a 500-piece collection ...' with image genesis-59.png (verified in a scratch Foundry test). Expected: the description reflects the final supply, or makes no fixed-count claim. Actual: it claims 77/500 for a 41-piece collection. A Genesis phase that is still short of 40 at the deadline gives the same result, with zero mystery tokens ever minted. Fix: build the counts from state once saleClosed (e.g. 'One of <genesisMinted> Genesis pieces in a <totalMinted>-piece collection'), or drop the fixed numbers from the descriptions. Expose the final supply through the views after close, and return 0 from remainingMystery*() once saleClosed.","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":"Hard-coded Genesis/Standard rarity claims ('One of 77 Genesis', 'One of 423 Standard', '500-piece collection') are false whenever the 30-day sale ends before sell-out"},{"citation":"resolved","description":"V3 replaced per-mint VRF requests with one global seed but kept V2 leftovers: the RevealStatus.RandomnessStored state, _randomWord, requestIdOf/tokenIdOfRequest, the RandomnessStored and VRFConfigSet events, CALLBACK_REVEAL_BATCH/GAS_PER_REVEAL/CALLBACK_GAS_RESERVE, the GenesisCIDNotSet/GenesisCIDAlreadySet/VRFNotConfigured errors, and genesisCIDLocked (always true). None of these is ever set, emitted or reachable. The VRF config is never announced by an event, MysteryMinted always carries requestId 0, and the pending artwork says 'REVEAL PENDING (Chainlink VRF)' even after the seed exists. Indexers and frontends built on the documented status machine will misreport state.","line":376,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Mint the 41st token, warp to the deadline, call requestSeed(), and have the coordinator fulfil it. revealStatusOf(41) returns 1 (AwaitingRandomness), not 2 (RandomnessStored), although the seed is stored, and requestIdOf(41) == 0. No VRFConfigSet log appears in the constructor receipt even though the config is set there. Fix: remove the unused state, constants, events and errors. Make revealStatusOf return RandomnessStored when seedReceived and the token is not yet finalized. Emit VRFConfigSet in the constructor.","severity":"info","snippet":"        return _revealStatus[tokenId];","title":"Dead V2 state and misleading status views in Candidate V3: revealStatusOf never reports RandomnessStored, requestIdOf always 0, VRFConfigSet/RandomnessStored never emitted"},{"citation":"resolved","description":"requestSeed() is permissionless and only requires the sale to be closable. If the deadline passes with totalMinted <= 40 (the Genesis phase unsold, or no mystery mints), anyone can still trigger a paid VRF request whose seed has nothing to reveal. finalizeReveals then loops zero times.","line":253,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Deploy, mint 0-40 Genesis tokens, warp to saleDeadline, then call requestSeed() from any EOA. A VRF request is created and charged to the subscription on fulfilment, and finalizeReveals(100) returns 0. Expected: a revert with nothing to reveal. Fix: require(totalMinted > GENESIS_PHASE_SUPPLY) in requestSeed().","severity":"info","snippet":"        if (!saleClosed) closeSale();","title":"requestSeed spends the VRF subscription even when no mystery token was sold"},{"citation":"resolved","description":"`forge test --offline` at bf2a63c gives 80 passed and 1 failed. test_RealBMCGenesisMint fails with 'Ethereum mainnet fork required' without a fork RPC, and it targets src/BMCIlluminatiPass.sol, not Candidate V3. The V3 suite (test/BMCIlluminatiPassCandidateV3.t.sol, 9 tests) runs entirely on MockBMC and a mock coordinator. Nothing tests V3's burnFrom against the real BMC token, the s_provingKeys staticcall against the real VRFCoordinatorV2_5 ABI, an under-funded or detached subscription, a partial-sale close, or the pending-to-revealed metadata transition. test_OnlyOneSeedRequestEvenAfterSevenDays encodes the permanent-stall behaviour as expected. script/DeployBMCIlluminatiPassV3DryRun.s.sol is simulation-only, and its target contract is marked 'EXPERIMENTAL V3 CANDIDATE - NOT FOR DEPLOYMENT'.","line":27,"path":"test/BMCMainnetFork.t.sol","reproduction":"Run `forge test --offline` at commit bf2a63c. Actual output: '80 tests passed, 1 failed ... [FAIL: Ethereum mainnet fork required] test_RealBMCGenesisMint()'. grep shows that no test file deploys BMCIlluminatiPassCandidateV3 against chain id 1. Fix: add a V3 mainnet-fork test (run with --fork-url) that deploys V3 with the dry-run parameters, has the subscription owner addConsumer, burns real BMC via mintGenesis, calls requestSeed against the real coordinator, and checks getSubscription balance and consumers. Add mock tests for the stall, consumer-removal and partial-sale cases above.","severity":"info","snippet":"        require(block.chainid == 1, \"Ethereum mainnet fork required\");","title":"Claimed '80 passing tests' do not cover Candidate V3's mainnet dependencies: the suite has 1 failing fork test (aimed at the older BMCIlluminatiPass) and only 9 V3 tests, all on mocks"}],"hash":"9967134592b1371568ed4b08a57aea5bbbe482af66f0ef396c1d5b1dcfa88d17","nodeId":"ac35852d-6606-4167-8407-bfc6fa768fd6","outcome":"completed","summary":"**Verdict: Candidate V3 is not ready for Ethereum Mainnet.** Its own header already says \"EXPERIMENTAL V3 CANDIDATE - NOT FOR DEPLOYMENT\", and the review confirms that. All 7 findings are in `.imd-findings.json`; no contract or config file was changed. Finding 1 was reproduced with a Foundry test that fails on the current code. Findings 3, 4 and 5 were confirmed in a scratch test. Findings 2 and 6 rest on reading the code plus read-only mainnet calls. Finding 7 is the suite's own output.\n\n**Test suite:** `forge test --offline` gives **80 passed, 1 failed**. The failure is `test_RealBMCGenesisMint`, which needs a mainnet fork and tests the older `BMCIlluminatiPass`, not V3. V3 has only 9 tests, all against mock BMC and mock Chainlink contracts.\n\n## Findings for Candidate V3\n\n| # | Severity | Issue |\n|---|---|---|\n| 1 | **High** | **Reveals can stall forever.** `requestSeed` asks Chainlink for randomness exactly once (`:255`), anyone can call it, and there is no retry or fallback. If that one request isn't answered (for example, someone fires it while the subscription is under-funded and Chainlink's 24h pending window passes), all mystery NFTs (up to 460, 161M BMC already burned) stay \"Pending\" permanently. |\n| 2 | Medium | **An outside account can block reveals.** The subscription, coordinator and key hash are fixed at deployment (`:204`). The subscription belongs to EOA `0x8302…41B0`, which has to add the contract as a consumer. If it never does, removes it, cancels the subscription or loses its key, `requestSeed` reverts on every call and there is no recovery. |\n| 3 | Low | **Outcomes leak before the reveal.** Once the seed is stored (`:308`), or while the fulfilment transaction is still in the mempool, anyone can compute every token's tier, but `tokenURI` still says \"Pending\". Example: seed 5 makes token #41 Genesis while it still shows as Pending. Uninformed holders and listings can be sniped. |\n| 4 | Low | **Supply claims are wrong after a partial sale.** The fixed metadata says \"One of 77 Genesis pieces in a 500-piece collection\" (`:459`, `:485`). If the 30-day sale ends early, that stays: a 41-token collection still claims 77/500. |\n| 5 | Info | **Leftover V2 code makes the status views misleading.** `revealStatusOf` never reports `RandomnessStored`, `requestIdOf` is always 0, and the `VRFConfigSet` event is never emitted. |\n| 6 | Info | **`requestSeed` spends subscription funds even when there is nothing to reveal**, e.g. when no mystery token was sold. |\n| 7 | Info | **Tests miss V3's mainnet dependencies**, and one existing test (`test_OnlyOneSeedRequestEvenAfterSevenDays`) asserts the permanent stall as intended behaviour. |\n\n## Recommended fixes\n- **Finding 1:** record when the seed was requested and allow a new request after about 24h if no seed has arrived; the new request ID replaces the old one, so a late answer to the stale request is rejected. This doesn't affect fairness because the sale is closed before any request. A minimal version of this patch, tried in a throwaway copy, makes the repro test pass.\n- **Finding 1, also:** have `requestSeed` check the subscription balance first, and lower `callbackGasLimit` from 500k (the callback needs under 100k), which reduces the minimum balance Chainlink requires.\n- **Finding 2:** have the contract create and own its own subscription in the constructor, or let the owner replace the subscription details only until a seed has been received.\n- **Finding 3:** derive tier and metadata as soon as the seed exists, and emit the ERC-4906 metadata-update event so marketplaces refresh immediately.\n- **Findings 4–6:** build supply counts from final state instead of fixed numbers, remove the dead V2 code, and require at least one mystery mint before `requestSeed`.\n\n## Deployment configuration (read-only checks on mainnet at block 26177084)\n- The key hash `0x3fd2…f10b` is registered on the coordinator, with a 500 gwei maximum gas price.\n- The subscription has 0.02 ETH, 0 LINK and **","treeHash":null,"usage":{"cachedInputTokens":1173268,"inputTokens":38,"model":"claude-opus-5-5","outputTokens":31213,"runtime":"claude","turns":21,"wallClockMs":371608}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"9df7d5d52e83c572","findings":[{"citation":"resolved","description":"Candidate V3 (unlike the production src/BMCIlluminatiPass.sol, which requests per mint, and Candidate V2, which allows a timed retry) issues exactly one VRF request for the whole collection. requestSeed() is callable by anyone the moment the sale is sold out or block.timestamp >= saleDeadline, and once seedRequestId is non-zero it can never be called again (line 255). rawFulfillRandomWords accepts only that id, the coordinator, subscription id and key hash are fixed at construction, and there is no timeout, re-request, owner recovery or fallback. The VRF v2.5 coordinator does not check the subscription balance when a request is made; Chainlink's subscription documentation states 'If your balance is below the minimum, your requests remain pending for up to 24 hours before they expire' (the README's claim that an unfunded request is held and fulfilled after funding is wrong beyond that window). So a request fired while the subscription is below the lane minimum and not topped up within 24 hours, a request whose fulfilment transaction fails, or a coordinator or node outage longer than the window leaves seedRequestId != 0 and seedReceived == false forever: finalizeReveals reverts 'seed pending', and every mystery token (ids 41..totalMinted, up to 460 tokens bought with up to 161,000,000 BMC destroyed via burnFrom) stays Tier.Pending with the 'Pending Reveal' metadata permanently. Because the request is permissionless, an outsider rather than the operator decides when the only request is made, for example in the block the deadline passes and before the subscription is funded. The dry-run configuration makes this realistic: on mainnet at block 26177145 the configured subscription holds 0.02 ETH native, 0 LINK and no consumers, while the 500 gwei lane with a 500,000 callback gas limit needs a minimum balance on the order of 0.4 ETH (see the deployment-configuration finding). The contract header acknowledges the stall but does not mitigate it, and test_OnlyOneSeedRequestEvenAfterSevenDays asserts it as intended behaviour. Fix (preserves single-seed fairness, because minting is closed before any request and no participant can act on a request's outcome): record seedRequestedAt and let requestSeed() run again while !seedReceived once block.timestamp >= seedRequestedAt + RETRY_DELAY with RETRY_DELAY longer than 24h (48h to 7 days); either overwrite seedRequestId so a late delivery of a superseded id is rejected by the existing id check, or record every issued id and accept the first word delivered for any of them (Candidate V2's issuedSeedRequests shape; the first delivered word is permanent either way). Defence in depth: before requesting, read VRF_COORDINATOR.getSubscription(subId) (part of the vendored IVRFCoordinatorV2Plus) and revert unless the balance in the asset selected by nativePayment exceeds a configured minimum, so a third party cannot consume the request while it is unpayable; and lower callbackGasLimit (the callback uses under 60k gas) to cut the lane minimum. Re-measure against the 984-byte EIP-170 margin.","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\";\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\";\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\n/// @dev Coordinator stand-in. Request ids are accepted and recorded, but the first request is\n///      never fulfilled: this models a VRF v2.5 request that expired off-chain (Chainlink keeps an\n///      unfunded request pending for at most 24 hours, then drops it) or whose callback failed.\ncontract ProofCoordinator {\n    bytes32 public constant KEY = keccak256(\"lane\");\n    uint256 public nextId = 1000;\n    mapping(uint256 => address) public consumerOf;\n\n    function s_provingKeys(bytes32 key) external pure returns (bool exists, uint64 maxGas) {\n        return (key == KEY, uint64(500 gwei));\n    }\n\n    function requestRandomWords(VRFV2PlusClient.RandomWordsRequest calldata) external returns (uint256 id) {\n        id = nextId++;\n        consumerOf[id] = msg.sender;\n    }\n\n    function fulfill(uint256 id, uint256 word) external returns (bool ok) {\n        uint256[] memory words = new uint256[](1);\n        words[0] = word;\n        (ok,) = consumerOf[id].call{gas: 500_000}(\n            abi.encodeWithSignature(\"rawFulfillRandomWords(uint256,uint256[])\", id, words)\n        );\n    }\n}\n\n/// @title Proof: a single unfulfilled seed request stalls every mystery reveal forever.\n/// @notice Fails on the current code: after the only VRF request has gone unfulfilled for a month,\n///         `requestSeed()` still reverts \"seed already requested\", so no seed can ever arrive and\n///         `finalizeReveals` reverts \"seed pending\" for all 460 mystery tokens (161,000,000 BMC\n///         already burned). Passes once the contract allows a timed re-request (e.g. `requestSeed()`\n///         callable again after a retry delay while `seedReceived` is false, accepting the first\n///         word delivered for any request id it issued).\ncontract V3SeedLivenessProofTest is Test {\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.KEY(), 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 < 460; ++i) pass.mintMystery();\n        vm.stopPrank();\n        assertEq(pass.totalMinted(), 500);\n        assertEq(bmc.balanceOf(alice), 0); // 201,000,000 BMC burned irreversibly\n    }\n\n    function test_SeedCanBeRequestedAgainAfterTheOnlyRequestIsNeverFulfilled() public {\n        // Anyone can request the seed the moment the sale is sold out.\n        address griefer = makeAddr(\"griefer\");\n        vm.prank(griefer);\n        uint256 first = pass.requestSeed();\n        assertEq(pass.seedRequestId(), first);\n\n        // The request is never fulfilled (expired off-chain after 24h, or callback failed).\n        vm.warp(block.timestamp + 31 days);\n        assertFalse(pass.seedReceived());\n        vm.expectRevert(bytes(\"seed pending\"));\n        pass.finalizeReveals(1);\n\n        // Liveness requirement: a new request must be possible once the old one is clearly dead.\n        uint256 second = pass.requestSeed();\n        assertTrue(second != 0 && second != first, \"a fresh request id must be issued\");\n\n        // And the fresh request must be able to deliver the seed and unblock reveals.\n        assertTrue(coord.fulfill(second, 0xBEEF));\n        assertTrue(pass.seedReceived());\n        assertEq(pass.finalizeReveals(460), 460);\n        assertEq(pass.revealedCount(), 460);\n    }\n}","reproduction":"Proof (forge test --match-path test/scratch/V3SeedLiveness.t.sol): mint 40 Genesis and 460 mystery tokens (201,000,000 BMC burned, minter balance 0); any address calls requestSeed() and gets id R = seedRequestId; the coordinator never delivers R (expired after 24h under-funded, or the fulfilment failed); vm.warp(+31 days); finalizeReveals(1) reverts 'seed pending'; requestSeed() reverts 'seed already requested'. Expected: a fresh request can be issued once the first is clearly dead and reveals proceed. Actual: no code path can ever produce a seed; all 460 tokens stay Tier.Pending. Verified: the specialist proofs Proof_f47f1ecdd235 and Proof_d7c20c60ac57 both fail on bf2a63c with 'seed already requested'; Proof_548cec8ac53c fails because requestSeed() succeeds against a subscription holding 0 of the configured payment asset ('next call did not revert as expected'); scratch test test_A_SecondRequestImpossibleForever shows the same after 365 days with totalBurned == 201,000,000e18 and pendingReveals() == 460. Mainnet reads (cast call on 0xD7f86b4b8Cae7D942340FF628F82735b7a20893a at block 26177145): s_provingKeys(0x3fd2fec1...f10b) = (true, 500 gwei); getSubscription(2785245948...9562985) = (0 LINK, 20000000000000000 wei native, 0 requests, owner 0x83027AD9d0D5374335086c539dADD1db983441B0, consumers []).","severity":"high","snippet":"        require(seedRequestId == 0, \"seed already requested\");","title":"Single, permissionless, non-retryable VRF seed request: one unfulfilled or expired request permanently stalls every mystery reveal after irreversible BMC burns"},{"citation":"resolved","description":"The constructor (lines 192-204) only range-checks the VRF parameters; it does not ask the coordinator whether keyHash is a registered proving key or whether subscriptionId exists, and V3 removed setVRFConfig (present in the production contract and still documented in the README). Ownable is inherited but no function is owner-gated, so nothing can ever change vrfConfig. The s_provingKeys probe in requestSeed() (lines 259-262) runs only after the sale closes, and the VRF v2.5 coordinator accepts requests for unknown key hashes without fulfilling them, which is why the probe exists. Three pre-request failures of the fixed configuration are therefore unrecoverable after up to 201,000,000 BMC has been burned: (a) a typo in the 77-digit subscription id or in the key hash at deployment (for example the VRF v2.0 mainnet lane 0xff8dedfb...4f92 instead of the v2.5 lane 0x3fd2fec1...f10b) passes the constructor and the fork dry run, which never calls requestSeed, and surfaces only as 'unregistered gas lane' or InvalidSubscription after the deadline; (b) the subscription owner on the coordinator (mainnet: EOA 0x83027AD9d0D5374335086c539dADD1db983441B0, distinct from the pass contract and outside its stated trust model) calls cancelSubscription during the sale, which the coordinator allows while no request is pending, i.e. during the whole 30-day sale; v2.5 subscription ids are hash-derived, so a replacement can never match the immutable id; (c) that key is lost, so the pass is never added as a consumer (the configured subscription currently has none) and nobody else can add it. Consumer removal or a missing consumer is recoverable, but only by that same key. This is distinct from the single-request stall: here requestSeed() never succeeds, seedRequestId stays 0, and retry logic would not help. Fix: (1) fail fast in the constructor by making the same s_provingKeys staticcall and calling VRF_COORDINATOR.getSubscription(subscriptionId) (reverts InvalidSubscription when it does not exist), reverting InvalidVRFConfig otherwise, and assert both in DeployBMCIlluminatiPassV3DryRun.run(); (2) add a recovery path that cannot bias outcomes because the sale is closed before any request: either an owner-only setVRFConfig callable only while seedRequestId == 0 (emitting the already-declared VRFConfigSet) documented as a trust assumption, or have the contract create and own its subscription in the constructor (createSubscription plus addConsumer(address(this)); anyone can fund it). Option 2 changes the 'VRF config fixed in the constructor' statement in the contract header and needs the requester's decision.","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 {BMCIlluminatiPassCandidateV3} from \"src/experimental/BMCIlluminatiPassCandidateV3.sol\";\nimport {VRFV2PlusClient} from \"@chainlink/contracts/src/v0.8/vrf/dev/libraries/VRFV2PlusClient.sol\";\n\n/// @dev Stand-in for the mainnet VRF v2.5 coordinator: like the real one, `requestRandomWords`\n///      does not validate the key hash, and `s_provingKeys` reports whether a lane is registered.\ncontract KeyAwareCoordinator {\n    bytes32 public constant REGISTERED = 0x3fd2fec10d06ee8f65e7f2e95f5c56511359ece3f33960ad8a866ae24a8ff10b;\n    uint256 public nextRequestId = 1000;\n\n    function s_provingKeys(bytes32 key) external pure returns (bool exists, uint64 maxGas) {\n        return (key == REGISTERED, key == REGISTERED ? uint64(500 gwei) : 0);\n    }\n\n    function requestRandomWords(VRFV2PlusClient.RandomWordsRequest calldata) external returns (uint256 id) {\n        id = nextRequestId++;\n    }\n}\n\n/// @notice Finding: an unregistered gas lane is accepted by the constructor and only rejected by\n///         `requestSeed()` after the sale has closed, when BMC has already been burned and the VRF\n///         config can no longer be changed. The constructor must reject a key hash the coordinator\n///         does not know, so the misconfiguration fails before any mint instead of after the sale.\ncontract V3UnregisteredKeyHashTest is Test {\n    KeyAwareCoordinator coord;\n\n    // VRF v2 (not v2.5) 500 gwei mainnet lane: a realistic copy-paste mistake.\n    bytes32 constant V2_KEY_HASH = 0xff8dedfbfa60af186cf3c830acbc32c05aae823045ae5ea7da1e45fbfaba4f92;\n\n    function setUp() public {\n        vm.warp(1_800_000_000);\n        coord = new KeyAwareCoordinator();\n    }\n\n    function _deploy(bytes32 key) internal returns (BMCIlluminatiPassCandidateV3) {\n        return new BMCIlluminatiPassCandidateV3(\n            address(this),\n            address(0xB0C),\n            address(coord),\n            uint64(block.timestamp + 30 days),\n            123,\n            key,\n            500_000,\n            3,\n            true,\n            \"bafybeihfihwwpev4gkqjuqawgtptsv3ynzsokclo6odsolvoa65aqaac2u\"\n        );\n    }\n\n    function test_RegisteredKeyHashDeploys() public {\n        BMCIlluminatiPassCandidateV3 pass = _deploy(coord.REGISTERED());\n        (, bytes32 keyHash,,,, bool configured) = pass.vrfConfig();\n        assertEq(keyHash, coord.REGISTERED());\n        assertTrue(configured);\n    }\n\n    /// @dev Fails on the current code: the constructor accepts any non-zero key hash, so the\n    ///      deployment succeeds and the lane error surfaces only in `requestSeed()` post-sale.\n    function test_UnregisteredKeyHashIsRejectedAtDeployment() public {\n        vm.expectRevert();\n        _deploy(V2_KEY_HASH);\n    }\n}","reproduction":"Proof (forge test --match-path test/scratch/V3UnregisteredKeyHash.t.sol): deploy V3 against a coordinator whose s_provingKeys reports the v2.0 lane 0xff8dedfbfa60af186cf3c830acbc32c05aae823045ae5ea7da1e45fbfaba4f92 as unregistered. Expected: the constructor reverts. Actual: deployment succeeds ('next call did not revert as expected'); mints then burn BMC for 30 days and every requestSeed() reverts 'unregistered gas lane' (the repository's own test_UnknownGasLaneCannotRequestSeed shows that dead end). Scratch test test_B_CancelledSubscriptionStallsForever against Chainlink's VRFCoordinatorV2_5Mock extended with s_provingKeys: createSubscription, addConsumer(pass), fund 100 LINK, mint 40 Genesis + 5 mystery (41,750,000 BMC burned), the subscription owner calls cancelSubscription(subId, to) before the deadline; at the deadline and 365 days later requestSeed() reverts inside requestRandomWords; seedRequestId() == 0, pendingReveals() == 5, vrfConfig() still returns the dead subscription id and no function can change it. Scratch test test_B_RemovedConsumerIsRecoverableOnlyBySubscriptionOwner: without addConsumer, requestSeed() reverts InvalidConsumer(subId, pass) and succeeds only after the subscription owner adds the consumer.","severity":"medium","snippet":"        vrfConfig = VRFConfig(subscriptionId, keyHash, callbackGasLimit, requestConfirmations, nativePayment, true);","title":"VRF subscription id and key hash are fixed at construction without validation and without any recovery path: a wrong lane, a cancelled subscription or a lost subscription-owner key stalls reveals befo"},{"citation":"resolved","description":"The dry-run script fixes callbackGasLimit = 500,000, requestConfirmations = 3 and nativePayment = true for the 500 gwei lane 0x3fd2fec1...f10b and subscription 2785245948...9562985. V3's callback only stores one word (measured at 36,577 gas inside the coordinator's call in a scratch run); the batch-finalization constants that justified 500,000 in the production contract are unused in V3, so the limit is roughly 9x the need. Chainlink computes the minimum subscription balance from the lane's maximum gas price times (200,000 verification gas + callbackGasLimit) plus the premium: 500 gwei x 700,000 = 0.35 ETH before the native premium, about 0.43 ETH with it, against about 0.22 ETH at the contract's 150,000 minimum. Read-only mainnet calls at block 26177145 show the subscription holds 0 LINK and 0.02 ETH native, has 0 requests, is owned by EOA 0x83027AD9d0D5374335086c539dADD1db983441B0 and has no consumers. As configured, the pass is not a consumer (requestSeed would revert InvalidConsumer until that owner adds it), and once added the single request would sit below the minimum and expire after 24 hours with no retry (see the single-request finding); the correct operational order is to fund above the lane minimum first and add the consumer last. The dry run, and the CI job that runs it, only checks owner, deadline and CID equality: it never asserts s_provingKeys(KEY_HASH).exists, never reads getSubscription(SUBSCRIPTION_ID) for balance and consumers, and never calls requestSeed, so the 'V3 deployment simulation' cannot catch any of this. The Genesis CID bafybeihfihww...aac2u could not be verified from this environment. Fix: set callbackGasLimit to the contract minimum (150,000, or lower MIN_CALLBACK_GAS_LIMIT to about 100,000 with headroom) unless the callback is changed to finalize a batch; make the dry run assert the lane is registered, read getSubscription and require nativeBalance (nativePayment=true) above the computed lane minimum with margin, and prank the subscription owner to addConsumer and call requestSeed on the fork; and document the fund-then-add-consumer order in the launch checklist.","line":37,"path":"script/DeployBMCIlluminatiPassV3DryRun.s.sol","reproduction":"cast call 0xD7f86b4b8Cae7D942340FF628F82735b7a20893a 's_provingKeys(bytes32)(bool,uint64)' 0x3fd2fec10d06ee8f65e7f2e95f5c56511359ece3f33960ad8a866ae24a8ff10b -> (true, 500000000000); cast call 0xD7f86b4b8Cae7D942340FF628F82735b7a20893a 'getSubscription(uint256)(uint96,uint96,uint64,address,address[])' 27852459484169864437638942739291618120641810244677084392617474192083559562985 -> (0, 20000000000000000, 0, 0x83027AD9d0D5374335086c539dADD1db983441B0, []) at block 26177145. Scratch test test_B_RemovedConsumerIsRecoverableOnlyBySubscriptionOwner: V3 deployed against Chainlink's VRFCoordinatorV2_5Mock with the pass not yet a consumer -> requestSeed() reverts InvalidConsumer(subId, pass). Scratch test test_C_OutcomePredictableBeforeFinalize logs 'callback gas used 36577' for rawFulfillRandomWords. Reading script lines 43-49: the only assertions are owner, deadline, CID locked and CID equality. Expected: the dry run proves the request can be made and paid with the committed constants. Actual: it proves only that the constructor runs, and the live subscription state would make the only request unpayable.","severity":"medium","snippet":"            500_000,\n            3,\n            true,","title":"Deployment configuration: 500,000 callback gas for a one-SSTORE callback inflates the lane minimum to about 0.4 ETH while the hard-coded mainnet subscription holds 0.02 ETH, 0 LINK and no consumers, a"},{"citation":"resolved","description":"rawFulfillRandomWords (lines 302-310) only stores globalSeed; unlike the production contract it finalizes nothing, although CALLBACK_REVEAL_BATCH, GAS_PER_REVEAL and CALLBACK_GAS_RESERVE are still declared and 500,000 callback gas is paid for. Every pending token's outcome is then a pure function of (globalSeed, tokenId) through the in-order virtual Fisher-Yates (r = keccak256(abi.encode(seed, tokenId)) % remaining), so anyone reading globalSeed() or watching the fulfilment transaction in the mempool knows which pending tokens will be Genesis (37 of 460, the 1,000,000-BMC tier) before holders and marketplaces do, while tierOf() returns Pending, artworkIdOf() returns 0, tokenURI() returns the 'Pending Reveal' JSON and revealStatusOf() keeps returning AwaitingRandomness (RevealStatus.RandomnessStored is never written in V3). The window lasts until someone pays for finalizeReveals(), which nobody is obliged to call and an informed buyer is incentivised not to; no ERC-4906 metadata event is emitted either. The pending JSON discloses that rarity can be calculated early, which keeps this Low, but the contract creates the window and leaves it open indefinitely. Fix: after storing the seed, finalize a gas-bounded batch inside the callback as src/BMCIlluminatiPass.sol does (batch from gasleft(), try this.finalizeReveals(batch) {} catch {}); derive revealStatusOf as RandomnessStored whenever seedReceived and the token is not yet finalized; optionally expose a view that computes a pending token's outcome from globalSeed so the information is symmetric; emit ERC-4906 BatchMetadataUpdate on seed receipt and MetadataUpdate on each reveal so marketplaces refresh.","line":306,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Scratch test test_C_OutcomePredictableBeforeFinalize: mint 40 Genesis + 10 mystery, warp to saleDeadline, requestSeed(), the coordinator delivers word 0xC0FFEE. Before any finalizeReveals call: revealStatusOf(41) == AwaitingRandomness (1), tierOf(41) == Pending (0), tokenURI(41) is byte-identical to the pre-seed pending JSON, revealedCount() == 0. Replaying the Fisher-Yates in memory from globalSeed() for tokens 41..50 predicts tier and artwork; finalizeReveals(10) then returns exactly those predictions for all 10 tokens. Expected: views and metadata reflect the outcome once it is determined, or the first batch is revealed atomically with the seed. Actual: outcomes are public and fixed while the contract still reports pending.","severity":"low","snippet":"        if (seedReceived) return;\n        seedReceived = true;\n        globalSeed = randomWords[0];","title":"Every mystery outcome is publicly computable from globalSeed before finalizeReveals runs, while tierOf, tokenURI and revealStatusOf still report Pending and the callback finalizes nothing"},{"citation":"resolved","description":"requestSeed() only requires that the sale can be closed (sold out or block.timestamp >= saleDeadline) and then sends the single paid VRF request. mintMystery() requires totalMinted >= 40, so if the 30-day deadline passes before all 40 Genesis tokens (1,000,000 BMC each, 40,000,000 BMC in total) have sold, no mystery token exists and pendingReveals() == 0, yet anyone can still trigger a request billed to the subscription at 500 gwei lane prices, and finalizeReveals() loops zero times. The same path exposes a design consequence that is nowhere documented or tested: a Genesis shortfall freezes the collection at the sold size forever, the 460 mystery positions are never offered while remainingMysteryMints() still reports 460, and after the deadline mintMystery() reverts SoldOut() rather than a deadline error (with 41 tokens sold it reports SoldOut while 459 remain). Fix: require(totalMinted > GENESIS_PHASE_SUPPLY, 'nothing to reveal') in requestSeed() (closeSale() can stay callable); add a SaleEnded error for the deadline branch of both mint functions; return 0 from remainingMysteryMints() once the sale is closed; and document, or consciously change, the rule that the mystery phase never opens without a Genesis sell-out.","line":252,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Scratch test test_D_RequestSeedWithNothingToReveal: 39 x mintGenesis(), warp to saleDeadline; mintGenesis() reverts GenesisPhaseOver, mintMystery() reverts GenesisPhaseNotOver before and after the deadline, pendingReveals() == 0, remainingMysteryMints() == 460. A random EOA calls requestSeed(): the coordinator's request counter increments (request created and billable on fulfilment), saleClosed() == true, and after fulfilment finalizeReveals(100) returns 0. Expected: revert because nothing needs randomness. Actual: a paid request is spent. Scratch test test_D_SoldOutErrorNameAfterDeadline: 40 Genesis + 1 mystery, warp to the deadline, mintMystery() reverts SoldOut() while remainingSupply() == 459.","severity":"low","snippet":"    function requestSeed() external returns (uint256 requestId) {\n        if (!saleClosed) closeSale();","title":"requestSeed() spends the single paid VRF request even when no mystery token exists; a Genesis shortfall at the deadline freezes the collection and mintMystery reports SoldOut"},{"citation":"resolved","description":"The sale closes permanently at saleDeadline (mint guards at lines 214 and 233, closeSale at line 245), and draws without replacement happen only for tokens actually sold, so after a partial sale most of the 37 mystery Genesis artworks and 423 Standard artworks never exist. The on-chain metadata is constant text: every Genesis token says 'One of 77 Genesis pieces in a 500-piece collection' (line 459), every Standard token 'One of 423 Standard pieces in a 500-piece collection' (line 485) and the Legendary token 'the rarest piece of the 500-piece collection' (line 458); phase-1 tokens 1..40 carry the same sentence. After a partial sale these claims misstate the supply and rarity the collection's value rests on, and remainingMysteryGenesis() and remainingMysteryStandard() keep reporting undrawn outcomes as 'still inside the random pool' after the sale is over. This is V3-specific: the production contract has no deadline, so there the counts become true at mint-out. Fix: build the counts from state once saleClosed (for example 'One of <GENESIS_PHASE_SUPPLY + mysteryGenesisRevealed> Genesis pieces in a <totalMinted>-piece collection' after all sold tokens are finalized) or drop the fixed numbers; return 0 from the remaining* views once saleClosed and expose the final supply after close.","line":459,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Scratch test test_E_PartialSaleMetadata: mint 40 Genesis + 1 mystery, warp to saleDeadline, requestSeed(), fulfil with seed 5 (keccak256(abi.encode(5, 41)) % 460 < 37), finalizeReveals(1). totalMinted() == totalSupply() == 41 (all Genesis), remainingMysteryGenesis() == 36, remainingMysteryStandard() == 423, remainingMysteryMints() == 459. Decoded tokenURI(41): {\"name\":\"BMC Illuminati Pass #41 - Genesis\",\"description\":\"... One of 77 Genesis pieces in a 500-piece collection minted by permanently burning BMC.\",\"image\":\"ipfs://bafycid/genesis-59.png\", ...}; tokenURI(2) carries the same sentence. Expected: metadata that reflects the final 41-piece supply or makes no fixed-count claim. Actual: 77 of 500 is claimed for a 41-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":"Fixed metadata claims ('One of 77 Genesis pieces in a 500-piece collection', 'One of 423 Standard pieces') and the remaining-pool views are false whenever the 30-day sale ends before sell-out"},{"citation":"resolved","description":"The nine V3 tests run only against V3RegisteredCoordinator, a hand-written mock that implements s_provingKeys but models neither billing, consumer registration nor request expiry. test_OnlyOneSeedRequestEvenAfterSevenDays (lines 82-94) asserts the permanent single-request stall as intended behaviour and then fulfils the request 8 days after it was sent, which the live network does not do (requests below the minimum balance expire after 24 hours). The repository's integration suites against Chainlink's official VRFCoordinatorV2_5Mock (ChainlinkMockIntegration, VRFRecovery*, VRFSafetyProperties) and the mainnet fork test target src/BMCIlluminatiPass.sol only; the official mock has no s_provingKeys getter, so V3's requestSeed() cannot execute against it at all. Untested for V3: an unfulfilled or expired request, an unfunded or LINK-only subscription with nativePayment=true, a cancelled subscription or missing consumer, the Genesis shortfall and partial-sale finalization, the pending-to-revealed metadata transition, redelivery of the seed, a callback delivered with exactly callbackGasLimit, and the gas profile of finalizing 460 tokens in batches. forge test --offline at bf2a63c reports 80 passed and 1 failed (test_RealBMCGenesisMint: 'Ethereum mainnet fork required', aimed at the production contract); CI excludes that contract and runs the fork test and the V3 dry run only when a MAINNET_RPC_URL secret exists. The claimed 80 passing tests therefore give little assurance about the property V3 hinges on. Fix: add a V3 suite on VRFCoordinatorV2_5Mock through a thin subclass exposing s_provingKeys (or vm.mockCall), cover the cases above, replace the 8-day fulfilment with the retry expectation once the liveness fix lands, and add a V3 fork test that deploys with the dry-run parameters, adds the consumer as the subscription owner and calls requestSeed against the real coordinator.","line":87,"path":"test/BMCIlluminatiPassCandidateV3.t.sol","reproduction":"forge test --offline at bf2a63c: '80 tests passed, 1 failed' with '[FAIL: Ethereum mainnet fork required] test_RealBMCGenesisMint()'. Scratch test test_I_OfficialMockRejectsRequestSeed: deploy V3 with address(new VRFCoordinatorV2_5Mock(0.1 ether, 1e9, 4e15)), createSubscription, addConsumer, fundSubscription(100 ether), warp to the deadline, requestSeed(). Expected (per the README's claim of a run against Chainlink's official mock): a request id. Actual: revert 'unregistered gas lane'. grep shows no test deploys BMCIlluminatiPassCandidateV3 against VRFCoordinatorV2_5Mock or a chain-id-1 fork, and lines 87-88 of the V3 test assert the stall and fulfil 8 days later.","severity":"low","snippet":"        vm.warp(block.timestamp + 8 days);\n        vm.expectRevert(bytes(\"seed already requested\"));","title":"V3 tests run only on a hand-written mock, encode the permanent stall and an 8-day fulfilment as expected behaviour, and cannot run against Chainlink's official VRFCoordinatorV2_5Mock; the '80 passing "},{"citation":"resolved","description":"Candidate V3 replaced per-mint requests with one global seed but kept the per-mint design's surface: RevealStatus.RandomnessStored is never written (revealStatusOf goes AwaitingRandomness -> Revealed), events RandomnessStored and VRFConfigSet are never emitted (the config is set in the constructor without an event, asymmetric with GenesisImagesCIDSet), requestIdOf and tokenIdOfRequest are public getters that always return 0, _randomWord is only ever deleted, MysteryMinted always carries requestId 0, CALLBACK_REVEAL_BATCH, GAS_PER_REVEAL and CALLBACK_GAS_RESERVE (lines 88-91) are unused, errors GenesisCIDAlreadySet, GenesisCIDNotSet and VRFNotConfigured guard conditions that are always false (genesisCIDLocked and configured are set true in the constructor), and Ownable is inherited although no function is onlyOwner, so owner(), transferOwnership and renounceOwnership have no effect while signalling a role to explorers. The pending artwork text 'REVEAL PENDING (Chainlink VRF)' also stays after the seed exists. Indexers and front ends built on the documented status machine (README: 'anyone can call finalizeReveals(n) if a callback left stored words') will misreport state. forge build --sizes reports the V3 runtime at 23,592 bytes, 984 bytes under the EIP-170 limit, so the fixes for the higher findings must be re-measured; removing the dead members recovers margin. Fix: delete the unused state, constants, events and errors; emit VRFConfigSet in the constructor; derive RandomnessStored from seedReceived; either drop Ownable or document that the V3 owner has no powers (and make it a multisig if the VRF-config recovery setter is added).","line":88,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"grep -n onlyOwner src/experimental/BMCIlluminatiPassCandidateV3.sol returns nothing; grep shows RandomnessStored, VRFConfigSet, requestIdOf, tokenIdOfRequest, CALLBACK_REVEAL_BATCH, GAS_PER_REVEAL and CALLBACK_GAS_RESERVE are declared but never assigned or emitted. Scratch test test_C_OutcomePredictableBeforeFinalize: after the coordinator delivers the seed, revealStatusOf(41) == 1 (AwaitingRandomness), never 2. The existing test_BurnAndMintDoNotRequestRandomness asserts requestIdOf(41) == 0 after a mystery mint. forge build --sizes --offline: 'BMCIlluminatiPassCandidateV3 | 23,592 | 25,449 | 984'. Expected: a surface that describes V3. Actual: getters, events and statuses that can never be observed.","severity":"info","snippet":"    uint256 public constant CALLBACK_REVEAL_BATCH = 4;","title":"Dead state, constants, events and errors inherited from the per-mint design, an inert Ownable and a misleading status machine make V3's public surface wrong for integrators; runtime is 984 bytes under"},{"citation":"resolved","description":"README.md and docs/SECURITY-REVIEW.md describe the production contract src/BMCIlluminatiPass.sol: one VRF request per mystery mint (lines 46-47), a gas-aware batch of reveals in the callback (48-49), owner setters setGenesisImagesCID and setVRFConfig (73-74, 100-104), a three-argument constructor (83) and an 'unavailable randomness' model in which an unfunded request is kept pending and fulfilled after funding (63-65). Candidate V3 has none of these: one post-sale seed, a ten-argument constructor, no owner setters, an immutable 30-day deadline, and its own header says 'EXPERIMENTAL V3 CANDIDATE - NOT FOR DEPLOYMENT' (line 14) while the CI workflow simulates its deployment. The README's funding statement is also wrong for the network: Chainlink's VRF v2.5 subscription documentation states that requests below the minimum balance 'remain pending for up to 24 hours before they expire', which is exactly what turns V3's single request into a permanent stall. Anyone deploying or operating from the README would deploy the wrong contract or misconfigure V3, and marketplaces reading it would misstate the reveal mechanics. The older experimental contracts (Candidate, Candidate V2, BMCPassVRFPrototype, BMCPostSaleSeedController) are not distinguished from V3 anywhere in the docs. Fix: decide which contract is the deployment candidate; if V3, remove the header, rewrite the README's mint mechanics, privileged-powers and deployment sections for V3, correct the 24-hour expiry statement, and scope docs/SECURITY-REVIEW.md to V3; otherwise mark V3 experimental in the README and stop simulating its deployment in CI.","line":63,"path":"README.md","reproduction":"Read README.md lines 46-47, 63-65, 73-74, 83 and 100-104 and compare with src/experimental/BMCIlluminatiPassCandidateV3.sol lines 14, 178-189, 228-240 and 250-276: grep -n 'function set' on the V3 source returns nothing, while the README instructs the owner to call setGenesisImagesCID and setVRFConfig after launch. docs/SECURITY-REVIEW.md line 3 scopes the review to src/BMCIlluminatiPass.sol. The Chainlink subscription page (docs.chain.link/vrf/v2-5/overview/subscription) states the 24-hour expiry. Expected: documentation that matches the deployment candidate and Chainlink's expiry behaviour. Actual: it documents a different contract and an incorrect liveness assumption.","severity":"info","snippet":"A pending token stays pending until its original request is fulfilled. There is no reroll,\nre-request or fallback. Chainlink VRF v2.5 keeps a request pending while the subscription is\nunfunded and fulfils it after funding, which anyone can do. The owner changing `setVRFConfig`","title":"README and security review document the production contract, contradict Candidate V3 (which is marked NOT FOR DEPLOYMENT) and misstate Chainlink's handling of unfunded requests"}],"hash":"c813c676da27eab8a3f583814725ae3f4e463f48cc996b569f209324ab048eea","nodeId":"d4e04965-0925-44ea-a9f9-71530c769ae1","outcome":"completed","summary":"The review is complete. The findings file is at the repository root, valid JSON with 9 findings, and no source, configuration or library file was modified. Scratch tests live only under the ignored test/scratch directory.\n\n**Verdict: Candidate V3 is not Mainnet-ready.** One high and two medium findings must be fixed first.\n\n**What I verified**\n\n- The existing suite reproduces as 80 passed, 1 failed. The failure is the mainnet fork test, which targets the production contract, not V3.\n- All four specialist proofs fail on the current code for the stated reasons. I kept two of them as proofs and folded the other two into merged findings.\n- Read-only mainnet calls confirm the configured gas lane is registered and the hard-coded subscription holds 0.02 ETH, 0 LINK and no consumers.\n- Chainlink's subscription docs confirm that requests below the minimum balance expire after 24 hours, which contradicts the README.\n- I wrote and ran eight scratch tests of my own for the findings that had no proof. All pass and show the stated behaviour.\n\n**Findings kept, after merging duplicates across the four specialists**\n\n1. **High.** Single, permissionless, non-retryable VRF seed request. One expired or failed request stalls every mystery reveal forever after irreversible burns. Merges four specialist reports, including the \"no funding precheck\" one, whose impact only exists because there is no retry. Proof attached.\n2. **Medium.** VRF subscription id and key hash are fixed without validation and with no recovery path. A deploy-time typo, a cancelled subscription or a lost subscription-owner key stalls reveals before any request exists. Merges four reports. Proof attached. I rated the key-hash variant medium rather than high because it needs an operator error.\n3. **Medium.** Deployment configuration. The 500,000 callback gas limit is about nine times the measured callback cost and inflates the lane minimum to roughly 0.4 ETH, while the live subscription holds 0.02 ETH and the dry run never exercises the VRF path.\n4. **Low.** Outcomes are computable from the public seed before finalization while views and metadata still say Pending. Merges five reports, including the never-reached RandomnessStored status.\n5. **Low.** The only seed request can be spent with nothing to reveal, and a Genesis shortfall freezes the collection with a misleading SoldOut error.\n6. **Low.** Fixed \"77 of 500\" rarity text and the remaining-pool views are false after a partial sale. Decoded metadata for a 41-token collection shows the claim.\n7. **Low.** Test coverage gap. The V3 suite only runs on a custom mock, encodes the stall as intended, and cannot run against Chainlink's official mock at all.\n8. **Info.** Dead state, events, constants and an inert Ownable. Runtime is 984 bytes under the EIP-170 limit.\n9. **Info.** README and security review describe the production contract and misstate Chainlink's expiry behaviour.\n\n**Dropped or demoted.** Nothing was dropped outright. Every specialist claim reproduced. The two unverifiable items, the IPFS CID contents and Chainlink's exact premium figures, are flagged as unverified inside the relevant findings rather than reported as defects.","treeHash":null,"usage":{"cachedInputTokens":2023965,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":68876,"runtime":"claude","turns":42,"wallClockMs":909267}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"63458a1bd2b2d376","findings":[{"citation":"resolved","description":"requestSeed() may be issued exactly once (seedRequestId must be 0) and rawFulfillRandomWords only accepts that id. Nothing in the contract can ever issue a second request, and the VRF config is immutable. Chainlink's VRF v2.5 subscription docs state: 'If your balance is below the minimum, your requests remain pending for up to 24 hours before they expire', and the security page states a callback that reverts 'will not attempt to call it a second time'. So any single missed fulfilment is final. requestSeed() is permissionless and becomes callable the second the sale sells out or block.timestamp >= saleDeadline, so an outsider (not the team) decides when the only request is made, e.g. while the subscription is below its minimum balance (it currently holds 0.02 ETH, see the deployment finding) or during a gas spike that pushes the lane's minimum balance above the funded amount. If the team does not top up within 24h the request expires off-chain; on-chain seedRequestId != 0 and seedReceived == false forever. Every mystery holder keeps a 'Pending Reveal' token; 460 * 350,000 = 161,000,000 BMC have already been burned irreversibly via burnFrom and cannot be refunded. The existing test test_OnlyOneSeedRequestEvenAfterSevenDays locks in this behaviour instead of catching it. The contract header acknowledges 'A permanently unfulfilled request may permanently prevent reveals' but this is not acceptable for a mainnet collection with irreversible burns. Fix (minimal, keeps single-seed fairness): allow requestSeed() to be called again while !seedReceived once block.timestamp >= lastSeedRequestAt + RETRY_DELAY with RETRY_DELAY strictly greater than Chainlink's 24h expiry (e.g. 48h-7d); record every issued request id (mapping) and let rawFulfillRandomWords accept the first word delivered for any recorded id (first delivered word is permanent, later ones ignored). Because the request moment is still chosen before the word exists and outcomes are hash(seed, tokenId) in mint order, no party can select an outcome; the only residual trust is the VRF node ordering, which is already assumed. Candidate V2 implements this shape (RETRY_DELAY = 7 days, issuedSeedRequests mapping).","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\";\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\";\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\n/// @dev Coordinator stand-in. Request ids are accepted and recorded, but the first request is\n///      never fulfilled: this models a VRF v2.5 request that expired off-chain (Chainlink keeps an\n///      unfunded request pending for at most 24 hours, then drops it) or whose callback failed.\ncontract ProofCoordinator {\n    bytes32 public constant KEY = keccak256(\"lane\");\n    uint256 public nextId = 1000;\n    mapping(uint256 => address) public consumerOf;\n\n    function s_provingKeys(bytes32 key) external pure returns (bool exists, uint64 maxGas) {\n        return (key == KEY, uint64(500 gwei));\n    }\n\n    function requestRandomWords(VRFV2PlusClient.RandomWordsRequest calldata) external returns (uint256 id) {\n        id = nextId++;\n        consumerOf[id] = msg.sender;\n    }\n\n    function fulfill(uint256 id, uint256 word) external returns (bool ok) {\n        uint256[] memory words = new uint256[](1);\n        words[0] = word;\n        (ok,) = consumerOf[id].call{gas: 500_000}(\n            abi.encodeWithSignature(\"rawFulfillRandomWords(uint256,uint256[])\", id, words)\n        );\n    }\n}\n\n/// @title Proof: a single unfulfilled seed request stalls every mystery reveal forever.\n/// @notice Fails on the current code: after the only VRF request has gone unfulfilled for a month,\n///         `requestSeed()` still reverts \"seed already requested\", so no seed can ever arrive and\n///         `finalizeReveals` reverts \"seed pending\" for all 460 mystery tokens (161,000,000 BMC\n///         already burned). Passes once the contract allows a timed re-request (e.g. `requestSeed()`\n///         callable again after a retry delay while `seedReceived` is false, accepting the first\n///         word delivered for any request id it issued).\ncontract V3SeedLivenessProofTest is Test {\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.KEY(), 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 < 460; ++i) pass.mintMystery();\n        vm.stopPrank();\n        assertEq(pass.totalMinted(), 500);\n        assertEq(bmc.balanceOf(alice), 0); // 201,000,000 BMC burned irreversibly\n    }\n\n    function test_SeedCanBeRequestedAgainAfterTheOnlyRequestIsNeverFulfilled() public {\n        // Anyone can request the seed the moment the sale is sold out.\n        address griefer = makeAddr(\"griefer\");\n        vm.prank(griefer);\n        uint256 first = pass.requestSeed();\n        assertEq(pass.seedRequestId(), first);\n\n        // The request is never fulfilled (expired off-chain after 24h, or callback failed).\n        vm.warp(block.timestamp + 31 days);\n        assertFalse(pass.seedReceived());\n        vm.expectRevert(bytes(\"seed pending\"));\n        pass.finalizeReveals(1);\n\n        // Liveness requirement: a new request must be possible once the old one is clearly dead.\n        uint256 second = pass.requestSeed();\n        assertTrue(second != 0 && second != first, \"a fresh request id must be issued\");\n\n        // And the fresh request must be able to deliver the seed and unblock reveals.\n        assertTrue(coord.fulfill(second, 0xBEEF));\n        assertTrue(pass.seedReceived());\n        assertEq(pass.finalizeReveals(460), 460);\n        assertEq(pass.revealedCount(), 460);\n    }\n}","reproduction":"State: 40 Genesis + 460 mystery minted (201,000,000 BMC burned). Step 1: any address calls requestSeed() -> request id R stored in seedRequestId. Step 2: the coordinator never delivers R (subscription below minimum balance for 24h so the node expires it, or the fulfilment tx reverted). Step 3: warp 31 days; requestSeed() reverts 'seed already requested'; finalizeReveals(1) reverts 'seed pending'; seedReceived() == false. Expected: a fresh request can be issued after the original is clearly dead and reveals proceed. Actual: no code path can ever produce a seed again; all 460 tokens stay Tier.Pending forever. Reproduced by test/scratch/V3SeedLivenessProof.t.sol, which fails on the current code with 'seed already requested'.","severity":"high","snippet":"        require(seedRequestId == 0, \"seed already requested\");","title":"Single VRF request with no re-request path: one expired or failed request permanently stalls all 460 mystery reveals"},{"citation":"resolved","description":"V3 fixes subscriptionId, keyHash and the coordinator at construction and removes the setVRFConfig(...) that the production contract (src/BMCIlluminatiPass.sol) and the README still describe. The seed is only requested after the 30-day sale. During that window the subscription owner (0x83027AD9... for the dry-run subscription, an EOA/role outside this contract) can cancelSubscription or removeConsumer (both allowed by the coordinator while no request is pending), or Chainlink can retire the 500 gwei proving key. In each case requestSeed() reverts forever: the staticcall probe fails with 'unregistered gas lane', or VRF_COORDINATOR.requestRandomWords reverts with InvalidSubscription/InvalidConsumer. There is no owner function, no fallback and no alternative subscription, so every mystery token (up to 161,000,000 BMC burned) is stuck in Pending. Because the request has not yet been made, this is distinct from the single-request expiry finding and has a different fix. Fix: keep the coordinator immutable but let the owner update subscriptionId/keyHash/callbackGasLimit/nativePayment only while seedRequestId == 0 (and emit VRFConfigSet, which is currently never emitted). Document that power as a trust assumption; it cannot influence outcomes because the seed is a VRF word used in mint order.","line":204,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"State: 40 Genesis + 460 mystery minted; the subscription owner calls cancelSubscription(subId, to) on the coordinator before anyone calls requestSeed() (or Chainlink deregisters the key hash). Then requestSeed() -> VRF_COORDINATOR.requestRandomWords reverts (InvalidSubscription) -> whole call reverts; vrfConfig() still returns subscriptionId 123, seedRequestId() == 0, pendingReveals() == 460, and no function can change the configuration. Expected: an operational recovery path to a new/funded subscription. Actual: permanent stall. Reproduced in test/scratch/V3Leads.t.sol::test_CancelledSubscriptionBeforeSeedHasNoRecovery (coordinator switched to reject requests) and the existing test_UnknownGasLaneCannotRequestSeed shows the proving-key branch of the same dead end.","severity":"medium","snippet":"        vrfConfig = VRFConfig(subscriptionId, keyHash, callbackGasLimit, requestConfirmations, nativePayment, true);","title":"VRF subscription id and key hash are immutable with no setter: a cancelled subscription, removed consumer or deregistered gas lane before requestSeed() succeeds stalls reveals permanently"},{"citation":"resolved","description":"The V3 callback only stores one word (measured 53,544 gas in test/scratch/V3Leads.t.sol::test_RedeliveryIgnoredAndCallbackGas); the 500,000 value was inherited from the production design that finalizes up to 4 reveals inside the callback. Chainlink's billing docs compute the minimum subscription balance as laneMaxGasPrice x (verification gas ~200,000 + callbackGasLimit) x (1 + premium). For the configured 500 gwei lane (KEY_HASH 0x3fd2fec1..., confirmed registered on the mainnet coordinator with maxGas 500 gwei via a read-only call) and nativePayment=true (24% premium): 500 gwei x 700,000 x 1.24 = 0.434 ETH. A read of getSubscription(27852459484169864437638942739291618120641810244677084392617474192083559562985) on the mainnet coordinator returns nativeBalance 0.02 ETH, LINK balance 0, consumers []. A request made at that balance stays pending and, per the Chainlink subscription docs, expires after 24h, which with the single-request design is fatal. With callbackGasLimit = 150,000 (the contract's MIN_CALLBACK_GAS_LIMIT) the minimum balance drops to about 0.217 ETH. Fix: set callbackGasLimit to the contract minimum 150,000 (or lower the constant to ~100,000 with headroom), fund the subscription above the lane's computed minimum before the sale can close (ideally several multiples, since gas price at fulfilment also matters), add the pass as consumer immediately after deployment, and add a deployment check that reads getSubscription and asserts balance >= computed minimum.","line":37,"path":"script/DeployBMCIlluminatiPassV3DryRun.s.sol","reproduction":"Input: deploy with the script's constants (callbackGasLimit 500_000, 500 gwei lane, nativePayment true) against subscription 2785...2985 as it stands on mainnet today (0.02 ETH native, 0 LINK). After the sale closes anyone calls requestSeed(). Expected: fulfilment within minutes. Actual per Chainlink's documented minimum-balance rule: minimum balance ~0.434 ETH > 0.02 ETH, request pends; if not topped up within 24h it expires and (see the high finding) can never be re-requested. Evidence: `cast call <coordinator> 's_provingKeys(bytes32)(bool,uint64)' 0x3fd2...` -> (true, 500000000000); `cast call <coordinator> 'getSubscription(uint256)(uint96,uint96,uint64,address,address[])' <subId>` -> (0, 20000000000000000, 0, 0x83027AD9d0D5374335086c539dADD1db983441B0, []).","severity":"medium","snippet":"            500_000,","title":"Deployment config: 500,000 callback gas limit is ~9x the measured 53,544-gas callback, inflating the lane's minimum subscription balance to about 0.43 ETH while the configured subscription holds 0.02 "},{"citation":"resolved","description":"mintMystery() requires totalMinted >= 40, and both mint paths close at saleDeadline. The Genesis price is 1,000,000 BMC, ~3x the mystery price, so a Genesis shortfall is a plausible outcome. After the deadline no further mint is possible, so the 77-Genesis/500-piece collection described in the metadata ('One of 77 Genesis pieces in a 500-piece collection') can never be completed, and the 37 mystery Genesis pieces plus 423 Standard pieces are unreachable. In the same state closeSale()/requestSeed() remain callable and make a paid VRF request although pendingReveals() == 0. This is a design decision to confirm rather than a code bug, but it is not documented anywhere and no test covers it. Fix options: let mystery minting open at the deadline-or-sell-out of phase 1 (i.e. allow mintMystery when block.timestamp >= a phase-1 deadline even if totalMinted < 40, with a separate overall deadline), or document the behaviour; and gate requestSeed() on totalMinted > GENESIS_PHASE_SUPPLY.","line":232,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Input: 39 x mintGenesis(), then warp to saleDeadline. mintMystery() reverts GenesisPhaseNotOver both before and after the deadline; mintGenesis() reverts GenesisPhaseOver after it; totalMinted stays 39 forever. requestSeed() succeeds and the coordinator records 1 request while pendingReveals() == 0. Reproduced in test/scratch/V3Leads.t.sol::test_GenesisShortfallClosesCollectionAndWastesSeedRequest.","severity":"low","snippet":"        if (totalMinted < GENESIS_PHASE_SUPPLY) revert GenesisPhaseNotOver();","title":"If fewer than 40 Genesis tokens sell before the 30-day deadline the collection is frozen at that size, the mystery phase never opens, and requestSeed() still spends a VRF request for zero mystery toke"},{"citation":"resolved","description":"After rawFulfillRandomWords stores globalSeed, every unfinalized mystery token still returns RevealStatus.AwaitingRandomness (1) although randomness is available and its outcome is fixed; the RandomnessStored value (2) is unreachable. Front ends and the README's 'Monitor pendingReveals(); anyone can call finalizeReveals' guidance cannot tell from the per-token view whether finalization is possible. mintMystery emits MysteryMinted with requestId 0 and never writes requestIdOf/tokenIdOfRequest; _finalize deletes _randomWord[tokenId] which is never written; events RandomnessStored and VRFConfigSet, error GenesisCIDAlreadySet and the genesisCIDLocked checks (always true after construction) are dead. Fix: derive the status (return RandomnessStored when seedReceived && status == AwaitingRandomness) and remove the unused members to shrink the 23,592-byte runtime.","line":376,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Input: 40 Genesis + 1 mystery minted, warp to deadline, requestSeed(), coordinator delivers word 77. Call revealStatusOf(41). Expected: RandomnessStored (2). Actual: AwaitingRandomness (1), while globalSeed() is public and finalizeReveals(1) would succeed. Reproduced in test/scratch/V3Leads.t.sol::test_OutcomeComputableBeforeFinalize (assertion on revealStatusOf(41) == AwaitingRandomness).","severity":"low","snippet":"        return _revealStatus[tokenId];","title":"revealStatusOf() never reports RandomnessStored after the seed arrives; RandomnessStored status/event and requestIdOf/tokenIdOfRequest/_randomWord are dead state inherited from the per-mint design"},{"citation":"resolved","description":"Once the callback stores globalSeed, tier and artwork of every pending token are fixed by keccak256(abi.encode(globalSeed, tokenId)) % remaining in mint order, but no on-chain state or view exposes that until someone pays for finalizeReveals(n). Marketplaces and holders see 'Pending Reveal' metadata, while anyone who reads globalSeed() can compute which pending tokens are Genesis and buy them from uninformed sellers. The window is unbounded because finalization is permissionless but unpaid; nobody is obliged to call it, and a sniper is incentivised not to. The production design mitigates this by finalizing a batch inside the callback. Fix: finalize a bounded batch inside rawFulfillRandomWords (store word first, then try finalizeReveals(CALLBACK_REVEAL_BATCH) with a gas check, as src/BMCIlluminatiPass.sol does) and/or expose a pure view predictedOutcome(tokenId) so the information is symmetric; also mention in the pending metadata that outcomes are already determined once seedReceived is true.","line":308,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Input: 40 Genesis + 10 mystery minted; after the deadline requestSeed(); the coordinator delivers a word W such that keccak256(abi.encode(W, 41)) % 460 < 37. Then tierOf(41) == Pending and tokenURI(41) is the pending JSON, but globalSeed() == W is readable and predicts Genesis with artwork 41 + r; a subsequent finalizeReveals(1) confirms exactly that. Reproduced in test/scratch/V3Leads.t.sol::test_OutcomeComputableBeforeFinalize.","severity":"low","snippet":"        globalSeed = randomWords[0];","title":"Outcomes are publicly computable from globalSeed before finalizeReveals runs while tokenURI/tierOf still say Pending, enabling informed buyers to snipe pending Genesis tokens"},{"citation":"resolved","description":"The 9 V3 tests use a hand-written coordinator that implements s_provingKeys; the repository's Chainlink-mock integration test (test/ChainlinkMockIntegration.t.sol) covers only the production contract. Chainlink's VRFCoordinatorV2_5Mock has no s_provingKeys, so V3's requestSeed() reverts 'unregistered gas lane' against it; the ABI of the probe is therefore validated only by this audit's read-only mainnet call, not by the suite. Untested V3 behaviour: finalizeReveals before the seed, redelivery of the same request id, partial sale finalization (fewer than 460 mystery mints), mintMystery/mintGenesis at exactly saleDeadline, Genesis shortfall, the 24h-expiry/no-re-request scenario, and the subscription-cancelled path. test_OnlyOneSeedRequestEvenAfterSevenDays asserts the stall behaviour as desired. Fix: add a V3 integration test using VRFCoordinatorV2_5Mock (via a thin subclass adding s_provingKeys), tests for the edges listed, and change the seven-day test to expect a retry once the liveness fix lands.","line":9,"path":"test/BMCIlluminatiPassCandidateV3.t.sol","reproduction":"Input: deploy V3 with address(new VRFCoordinatorV2_5Mock(0.1 ether, 1e9, 4e15)), createSubscription, addConsumer, fundSubscription(100 ether), warp to the deadline, call requestSeed(). Expected (per README claim of a run against Chainlink's official mock): a request is created. Actual: revert 'unregistered gas lane'. Reproduced in test/scratch/V3Leads.t.sol::test_OfficialChainlinkMockRejectsRequestSeed; the other untested edges are exercised in the same scratch file.","severity":"low","snippet":"contract V3RegisteredCoordinator is MockVRFCoordinator {","title":"V3 test suite never runs against Chainlink's official VRFCoordinatorV2_5Mock and skips key edge cases; against the official mock requestSeed() cannot execute at all"},{"citation":"resolved","description":"README 'Mint mechanics', 'Unavailable randomness', 'Privileged powers' and 'Deployment' sections describe src/BMCIlluminatiPass.sol: one VRF request per mystery mint, owner setters, three-argument constructor, 'anyone can fund' recovery. V3 uses one post-sale seed, a 10-argument constructor, no owner setters, an immutable 30-day deadline and (per its own line 14) is 'EXPERIMENTAL V3 CANDIDATE - NOT FOR DEPLOYMENT'. docs/SECURITY-REVIEW.md scopes only the production contract. Anyone deploying from the README would deploy the wrong contract or misconfigure V3; marketplaces reading the README would misstate the reveal mechanics. Fix: either promote V3 explicitly (update README/docs, remove the header) or keep it experimental and say so in the README.","line":46,"path":"README.md","reproduction":"Read README.md line 46-47 ('mints a pending-reveal NFT and opens one VRF request') and line 83 ('Constructor: (address initialOwner, address bmcToken, address vrfCoordinator)'), then compare with src/experimental/BMCIlluminatiPassCandidateV3.sol lines 14, 178-189 and 228-240. Expected: documentation matches the deployment candidate. Actual: it documents a different contract.","severity":"info","snippet":"- **Phase 2, ids 41-500.** `mintMystery()` burns 350,000 BMC, mints a pending-reveal NFT and opens","title":"README and docs describe the production contract (per-mint VRF, setGenesisImagesCID, setVRFConfig) and contradict Candidate V3; V3 itself is annotated NOT FOR DEPLOYMENT"},{"citation":"resolved","description":"V3 removed every onlyOwner function, so the owner can do nothing but transfer or renounce ownership. This is not a vulnerability (there is no privilege to abuse) but it adds bytecode (runtime is 23,592 bytes, 984 under EIP-170) and signals a role to explorers and marketplaces that does not exist. If the VRF-config recovery setter recommended in the immutable-config finding is added, the role becomes meaningful and should be a multisig (docs open item O-5); otherwise drop Ownable.","line":35,"path":"src/experimental/BMCIlluminatiPassCandidateV3.sol","reproduction":"Grep for onlyOwner in src/experimental/BMCIlluminatiPassCandidateV3.sol: no matches. owner() returns initialOwner; the only owner-callable functions are OpenZeppelin's transferOwnership and renounceOwnership.","severity":"info","snippet":"contract BMCIlluminatiPassCandidateV3 is ERC721, Ownable, ReentrancyGuard {","title":"Ownable is inherited but no function in V3 is owner-gated; the owner role is vestigial while transferOwnership/renounceOwnership remain exposed"}],"hash":"ddd93515b77d9ddb23470e2064586a8a41381f120cc472ca004a936245f8ca53","nodeId":"f6a46128-22c5-461d-8ad4-d26c7b5fe9fe","outcome":"completed","summary":"The audit of BMC Illuminati PASS Candidate V3 is complete. Findings are in `.imd-findings.json` at the repository root (9 entries, 1 with an executable proof). No source, config or library file was changed; the only files I wrote are the findings file and two scratch tests under test/scratch.\n\n**Mainnet readiness verdict: not ready.** The contract's own header says \"NOT FOR DEPLOYMENT\", and the review confirms that judgement for one structural reason: a single VRF request with no recovery path, combined with a subscription that is currently funded far below the lane's minimum.\n\n**Verification performed**\n\n- Full suite: 80 tests pass. The one failure is the mainnet-fork test, which needs an RPC and is not a defect.\n- Read-only mainnet calls confirmed the configured key hash is registered on the real coordinator with the expected `(bool,uint64)` getter, BMC has 18 decimals, and the dry-run subscription exists with 0.02 ETH, 0 LINK and no consumers.\n- Chainlink docs confirm unfunded requests expire after 24 hours and a reverted callback is never retried.\n- Seven scratch tests reproduce the leads below; the High proof fails on current code and passes on a patched copy.\n\n**Findings by severity**\n\n- **High, requestSeed single-shot (line 255).** One expired or failed request stalls all 460 mystery reveals forever, after 161M BMC has been burned irreversibly. Anyone can trigger the request the second the sale closes, so the team does not control timing. Fix: timed re-request while no seed has been received, accepting the first word delivered for any issued id. The existing seven-day test asserts the stall as desired and should be flipped.\n- **Medium, immutable VRF config (line 204).** A cancelled subscription, removed consumer or retired gas lane during the 30-day sale leaves no way to ever request the seed. V3 dropped the setter the production contract has. Fix: owner-settable config only while no request has been made.\n- **Medium, deployment config (script line 37).** Callback gas limit of 500,000 is about 9x the measured 53,544-gas callback. That inflates the 500 gwei lane's minimum balance to roughly 0.43 ETH against 0.02 ETH funded, which with the 24-hour expiry makes the High finding likely rather than theoretical.\n- **Low.** Genesis shortfall before the deadline freezes the collection below 40 tokens and still allows a paid, useless seed request. `revealStatusOf` never reports stored randomness and several mappings and events are dead. Outcomes are computable from the public seed while metadata still says Pending, enabling sniping of pending Genesis tokens. The V3 suite never runs against Chainlink's official mock, where `requestSeed` cannot execute at all, and skips the stall, cancellation, partial-sale and deadline-boundary edges.\n- **Info.** README and docs describe the production contract, not V3. Ownable is inherited but nothing is owner-gated.\n\n**Verified as sound:** burn accounting with exact supply and balance deltas, reentrancy ordering on both mints, coordinator-only callback with redelivery ignored, deterministic in-order Fisher-Yates draw with no duplicates across 460 outcomes, deadline boundary consistency, and the proving-key probe's ABI against the live coordinator. Chainlink's own `ownerCancelSubscription` and node ordering remain trust assumptions on Chainlink.","treeHash":null,"usage":{"cachedInputTokens":1415571,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":47585,"runtime":"claude","turns":41,"wallClockMs":690082}}],"verification":[]}