{"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":"de963da7-456d-4f11-a8cf-3b6dac017133","kind":"audit","nodes":[{"acceptedSubmissionHash":"4f4afc30897ec6dc0358cf0ebadfce48f136d2964149b7248582296f20c4fad8","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":"727f743b1fa2c31c860207fedbc072d074b41808fb063eb3f16a15db212d3847","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":"bba3408249ecfbcc380e9ec156b5fd47764d26de344da792a3935f385a40bb39","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":"586baab44c0568fe76e85c949de81c5e04b02b312e54cbeb674db236ca3407f5","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":"30ab8d75cc752ad56d60cc9ec24350f7e7e75c6d3ee374f1a6420f18adb74f26","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":"PROJECT: BMC ILLUMINATI PASS — PRE-MAINNET SECURITY AUDIT\n\nAudit my existing Solidity ERC-721 collection before Ethereum mainnet deployment.\n\nRepository:\nhttps://github.com/0xSt3w/launch-1356-project-bmc-illuminati-pass\n\nNetwork: Ethereum Mainnet, chain ID 1.\n\nBMC token:\n0x38224239f2BDae357D47cb864BC533d7f72dF961\n\nChainlink VRF v2.5 coordinator:\n0xD7f86b4b8Cae7D942340FF628F82735b7a20893a\n\nGenesis IPFS directory CID:\nbafybeihfihwwpev4gkqjuqawgtptsv3ynzsokclo6odsolvoa65aqaac2u\n\nAUDIT SCOPE\n\nReview all relevant Solidity contracts, deployment scripts, libraries, Foundry tests, VRF integrations and token interactions.\n\n1. NFT SUPPLY AND MINTING\n- Exactly 500 ERC-721 tokens.\n- First 40 mints are sequential Genesis.\n- Genesis #1 goes to the first successful minter.\n- First 40 mints burn 1,000,000 BMC each.\n- Remaining 460 mystery mints burn 350,000 BMC each.\n- Exactly 37 additional Genesis and 423 Standard outcomes.\n- No duplicate artwork assignments.\n- No minting beyond 500 tokens.\n\n2. BMC BURN SECURITY\n- Verify real BMC burnFrom compatibility.\n- Verify 18 decimals and allowance handling.\n- Confirm totalSupply and payer balance decrease correctly.\n- Test failures, reentrancy and unexpected token behavior.\n- Maximum total burn: 201,000,000 BMC.\n\n3. CHAINLINK VRF SECURITY\n- Verify Ethereum mainnet coordinator address and ABI compatibility.\n- Verify VRF v2.5 subscription requirements.\n- Review callback gas limits and payment configuration.\n- Test concurrent requests and out-of-order fulfillment.\n- Confirm random allocation without replacement.\n- Investigate permanently pending reveals and coordinator failures.\n- Review the consequences of owner-controlled VRF configuration.\n\n4. IPFS AND METADATA\n- Verify all 77 Genesis image paths.\n- Verify Genesis #1 points to genesis-01.png.\n- Confirm CID locking and metadata immutability.\n- Review Base64 JSON and on-chain Standard SVG rendering.\n- Check uniqueness of all 423 Standard combinations.\n- Confirm the artwork generator cannot accidentally overwrite the approved premium Genesis files.\n\n5. OWNER PERMISSIONS\n- Review setGenesisImagesCID and setVRFConfig.\n- Review transferOwnership and renounceOwnership.\n- Confirm no free owner mint, hidden withdrawals or upgrades.\n- Identify any risks from privileged configuration.\n\n6. DEPLOYMENT\n- Review script/Deploy.s.sol.\n- Verify owner, BMC and VRF coordinator parameters.\n- Verify foundry.toml and bytecode_hash = \"none\".\n- Check deployment size, gas estimates and Etherscan verification.\n- Review readiness for Ethereum mainnet.\n\n7. TESTING\n- Independently execute forge build and forge test.\n- Review the 43 existing Foundry tests.\n- Execute tests on an Ethereum mainnet fork using the actual BMC contract.\n- Add tests for uncovered edge cases.\n- Review invariants, gas limits and failure recovery.\n\nDELIVERABLES\n\nProvide:\n- Security findings classified Critical, High, Medium, Low and Informational.\n- Reproduction steps and proof-of-concept tests for significant findings.\n- Specific Solidity fixes.\n- Updated Foundry tests.\n- GitHub audit report.\n- Clear list of unresolved risks.\n- Mainnet deployment readiness assessment.\n\nIMPORTANT\n\nDo not deploy any contracts.\nDo not execute real BMC burns or token approvals.\nDo not request private keys or seed phrases.\nDo not change mint prices, supply or artwork without authorization.\nDo not claim that automated checks constitute a complete independent security audit.\n\nReport any blockers before requesting payment.","parentJobId":null,"planHash":"a1cf93ad5ef1757c9a723082e9580ae90a24723688e5c88926732d92bb14399a","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"de963da7-456d-4f11-a8cf-3b6dac017133","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":"52264","feedbackHash":"55e5461bc625e03073a61aee25cb5e762ac559f52ec86842c6e91fa1ea870b2b","nodeKey":"audit_economics","submissionHash":"4f4afc30897ec6dc0358cf0ebadfce48f136d2964149b7248582296f20c4fad8","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52222","feedbackHash":"45169b7bd667c8b56407d4a0db8f329de8fb6c69df907941b93c0f3834124919","nodeKey":"audit_flow","submissionHash":"727f743b1fa2c31c860207fedbc072d074b41808fb063eb3f16a15db212d3847","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52468","feedbackHash":"dd5f447ac615cbd723c827dc4aedf28ebc8a80035d83176930e0fa7c72d3e0c3","nodeKey":"audit_judge","submissionHash":"bba3408249ecfbcc380e9ec156b5fd47764d26de344da792a3935f385a40bb39","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51530","feedbackHash":"6b8b1a68f79cac29af9b282fc0fdff870ade8cb60bd650b03f0c134553ea425a","nodeKey":"audit_math","submissionHash":"586baab44c0568fe76e85c949de81c5e04b02b312e54cbeb674db236ca3407f5","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51425","feedbackHash":"40a4a59881bdc2cc3ec29c22f72519960fe821f5757e2ae5a03a359d628b46c9","nodeKey":"audit_permissions","submissionHash":"30ab8d75cc752ad56d60cc9ec24350f7e7e75c6d3ee374f1a6420f18adb74f26","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"4583d7c9d85ab510b0cc0625d00fb6d08dd2ec27c8c8d5a76685f0674cad1515","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"bcb8a3f211778fd6","findings":[{"citation":"resolved","description":"finalizeReveals walks from token GENESIS_PHASE_SUPPLY + revealedCount + 1 and stops at the first token that is not in RandomnessStored. A token whose request is still AwaitingRandomness therefore blocks the finalization of every later token, even when their random words are already stored on-chain. The contract has no re-request, no timeout, no skip and no owner recovery, so the block is permanent. The design (README 'Unavailable randomness', contract header) accepts that *the affected token* stays pending; it does not disclose that all later tokens are frozen with it, and the test suite never exercises a head token that is never fulfilled (test_OutOfOrderFulfillmentFinalizesInMintOrder eventually fulfils 41).\n\nHow a request becomes permanently unfulfilled on mainnet: (1) Chainlink VRF v2.5 docs (Subscription overview): 'If your balance is below the minimum, your requests remain pending for up to 24 hours before they expire' and 'pending requests automatically process as long as they have not expired'. So the README claim that an unfunded request 'is fulfilled once it is funded again' is wrong past 24h. The real coordinator (verified: 0xD7f8...893a, s_config minConf=3, maxGas=2.5M) accepts requests regardless of balance, and so does the vendored mock (test_UnfundedSubscriptionKeepsTokenPendingUntilFunded). (2) Owner sets nativePayment=true on a LINK-only subscription (or vice versa) via setVRFConfig: fulfilments revert InsufficientBalance until native funds arrive; past 24h the requests expire. (3) Any callback that the coordinator records as failed loses the word ('If your fulfillRandomWords() implementation reverts, the VRF service will not attempt to call it a second time', Chainlink VRF security page). Once a request has expired, Chainlink's own guidance forbids re-requesting, and the contract could not anyway.\n\nImpact: with the sale at, say, token 300 and token 41's request expired, holders of 42..300 (and all 460 eventually) each burned 350,000 BMC for an NFT that stays 'Pending Reveal' forever; the 37 mystery Genesis pieces are never assigned. No actor can repair it.\n\nFix (keeps the fixed-order anti-bias property for the normal path): record the request block/timestamp per mystery token and keep a separate finalization cursor from revealedCount. In finalizeReveals, if the head token is still AwaitingRandomness and older than a timeout (e.g. 7 days, well past Chainlink's 24h expiry), advance the cursor past it without drawing. A skipped token whose word later arrives is finalized immediately inside rawFulfillRandomWords (so only Chainlink's delivery order, not a user, decides when it is drawn); raise MIN_CALLBACK_GAS_LIMIT to cover store + one reveal (~170k measured). remaining = MYSTERY_SUPPLY - revealedCount stays correct because revealedCount counts draws, not cursor position. Also add a mint-time guard (see the separate medium finding) so requests are not opened against an unfundable subscription.","line":321,"path":"src/BMCIlluminatiPass.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {BMCIlluminatiPass} from \"src/BMCIlluminatiPass.sol\";\nimport {MockBMC} from \"test/mocks/MockBMC.sol\";\nimport {MockVRFCoordinator} from \"test/mocks/MockVRFCoordinator.sol\";\n\n/// @notice One mystery token whose VRF request is never fulfilled (e.g. the request expired after\n///         Chainlink's 24h underfunded-subscription window, or the callback was recorded as failed)\n///         must not freeze the reveal of every later token whose randomness HAS been delivered.\n///\n///         On the current code `finalizeReveals` stops at the first token that is not in\n///         `RandomnessStored`, so token 42's stored word is never consumed: 42..500 stay \"Pending\n///         Reveal\" forever although their randomness is on-chain, and no owner or user action can\n///         unblock them. This test fails on the current code and passes once a stuck head token no\n///         longer blocks later finalizations (for example a timeout after which the head may be\n///         skipped and finalized later when/if its word arrives).\ncontract HeadOfLineBlockingTest is Test {\n    MockBMC bmc;\n    MockVRFCoordinator vrf;\n    BMCIlluminatiPass pass;\n\n    address owner = makeAddr(\"owner\");\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n    address carol = makeAddr(\"carol\");\n\n    function setUp() public {\n        bmc = new MockBMC();\n        vrf = new MockVRFCoordinator();\n        pass = new BMCIlluminatiPass(owner, address(bmc), address(vrf));\n        vm.startPrank(owner);\n        pass.setGenesisImagesCID(\"bafytest\");\n        pass.setVRFConfig(42, keccak256(\"lane\"), 500_000, 3, false);\n        vm.stopPrank();\n        for (uint256 i = 0; i < 40; i++) {\n            address who = address(uint160(0x1000 + i));\n            bmc.mint(who, 1_000_000e18);\n            vm.startPrank(who);\n            bmc.approve(address(pass), 1_000_000e18);\n            pass.mintGenesis();\n            vm.stopPrank();\n        }\n    }\n\n    function _mystery(address who) internal returns (uint256 id, uint256 req) {\n        bmc.mint(who, 350_000e18);\n        vm.startPrank(who);\n        bmc.approve(address(pass), 350_000e18);\n        (id, req) = pass.mintMystery();\n        vm.stopPrank();\n    }\n\n    function test_StuckHeadTokenDoesNotFreezeLaterRevealsForever() public {\n        (uint256 a,) = _mystery(alice); // 41: its request is never fulfilled\n        (uint256 b, uint256 rb) = _mystery(bob); // 42\n        (uint256 c, uint256 rc) = _mystery(carol); // 43\n        assertEq(a, 41);\n        assertEq(b, 42);\n        assertEq(c, 43);\n\n        // Randomness for 42 and 43 is delivered and stored on-chain.\n        assertTrue(vrf.fulfill(rb, 0xB0B));\n        assertTrue(vrf.fulfill(rc, 0xCA201));\n        assertEq(uint8(pass.revealStatusOf(b)), uint8(BMCIlluminatiPass.RevealStatus.RandomnessStored));\n        assertEq(uint8(pass.revealStatusOf(c)), uint8(BMCIlluminatiPass.RevealStatus.RandomnessStored));\n\n        // Token 41's request is never fulfilled. A long time passes; everyone tries to finalize.\n        vm.warp(block.timestamp + 30 days);\n        vm.roll(block.number + 216_000);\n        vm.prank(bob);\n        pass.finalizeReveals(10);\n        vm.prank(carol);\n        pass.finalizeReveals(10);\n        vm.prank(owner);\n        pass.finalizeReveals(10);\n\n        // Expected: 42 and 43, whose randomness is on-chain, are revealed; 41 may stay pending.\n        // Actual (current code): finalizeReveals returns 0 forever, 42 and 43 remain Tier.Pending.\n        assertEq(\n            uint8(pass.revealStatusOf(b)),\n            uint8(BMCIlluminatiPass.RevealStatus.Revealed),\n            \"token 42 has stored randomness but is frozen behind unfulfilled token 41\"\n        );\n        assertEq(\n            uint8(pass.revealStatusOf(c)),\n            uint8(BMCIlluminatiPass.RevealStatus.Revealed),\n            \"token 43 has stored randomness but is frozen behind unfulfilled token 41\"\n        );\n        assertTrue(pass.tierOf(b) != BMCIlluminatiPass.Tier.Pending);\n        assertTrue(pass.tierOf(c) != BMCIlluminatiPass.Tier.Pending);\n        assertEq(uint8(pass.revealStatusOf(a)), uint8(BMCIlluminatiPass.RevealStatus.AwaitingRandomness));\n    }\n}","reproduction":"State: phase 1 complete (totalMinted = 40), VRF configured. alice mintMystery() -> token 41, request R41. bob mintMystery() -> 42, R42. carol mintMystery() -> 43, R43. Coordinator fulfils R42 and R43 (callback stores words; revealStatusOf(42) == revealStatusOf(43) == RandomnessStored). R41 is never fulfilled (expired after 24h underfunded, or callback recorded as failed). Warp 30 days. Anyone calls finalizeReveals(10). Expected: 42 and 43, whose randomness is on-chain, become Revealed (41 may stay pending). Actual: finalizeReveals returns 0 forever, revealStatusOf(42) stays 2 (RandomnessStored), tierOf(42) == Pending, tokenURI(42) is the 'Pending Reveal' placeholder; same for 43 and every later token. Run: forge test --match-path test/scratch/HeadOfLineBlocking.t.sol -> FAIL 'token 42 has stored randomness but is frozen behind unfulfilled token 41: 2 != 3'.","severity":"high","snippet":"        while (done < maxCount && tokenId <= totalMinted && _revealStatus[tokenId] == RevealStatus.RandomnessStored) {","title":"Strict in-order finalization lets one never-fulfilled VRF request freeze every later reveal forever"},{"citation":"resolved","description":"Trust gap (access x economics): the buyer's irreversible payment is taken before the only external dependency is checked for solvency. mintMystery verifies cfg.configured and relies on VRF_COORDINATOR.requestRandomWords reverting to roll back the burn (tests test_MysteryBurnRevertsIfVRFRequestFails / test_NotAConsumerReverts). But Chainlink VRF v2.5 accepts a request from a registered consumer whose subscription holds zero LINK and zero native balance; payment is only attempted at fulfilment. The vendored Chainlink mock behaves the same and the repo's own test_UnfundedSubscriptionKeepsTokenPendingUntilFunded shows the mint succeeding with an unfunded subscription. Chainlink's docs state that such requests expire after 24 hours, and the contract has no re-request path, so a buyer who mints while the subscription is empty (a state the buyer cannot see from the mint UI, and which is controlled by the subscription owner, a privileged role distinct from the contract owner) permanently loses 350,000 BMC for a token that can never be revealed. Because reveals are strictly ordered, that token also freezes every later reveal (see the high finding). The README text 'Chainlink VRF v2.5 keeps a request pending while the subscription is unfunded and fulfils it after funding' is contradicted by Chainlink's 24h expiry.\n\nFix: before _burnExact, read (uint96 balance, uint96 nativeBalance,,,) = VRF_COORDINATOR.getSubscription(cfg.subscriptionId) (IVRFCoordinatorV2Plus inherits IVRFSubscriptionV2Plus, so no interface change) and revert with a new SubscriptionUnfunded error when the balance matching cfg.nativePayment is zero (or below a conservative per-request minimum, e.g. an owner-set minSubscriptionBalance bounded to a sane range). This fails closed exactly like the existing VRFNotConfigured path and does not change price, supply or artwork. Also surface pendingReveals/subscription balance on the website so buyers can see the sale is healthy.","line":248,"path":"src/BMCIlluminatiPass.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {VRFCoordinatorV2_5Mock} from \"@chainlink/contracts/src/v0.8/vrf/mocks/VRFCoordinatorV2_5Mock.sol\";\nimport {BMCIlluminatiPass} from \"src/BMCIlluminatiPass.sol\";\nimport {MockBMC} from \"test/mocks/MockBMC.sol\";\n\n/// @notice `mintMystery` burns 350,000 BMC and opens a VRF request even when the configured\n///         subscription holds no LINK and no native balance, so the request can never be paid for.\n///         Chainlink's coordinator accepts such requests and (per Chainlink docs) drops them after\n///         24 hours if the subscription stays underfunded; the contract has no re-request path, so\n///         the buyer is left with a permanently pending token and the BMC is gone.\n///\n///         Expected: the mint fails closed (no burn) while the subscription cannot pay for fulfilment,\n///         exactly like it already does when the VRF config is missing or the request itself reverts.\n///         Actual (current code): the burn succeeds and the pending token is minted.\n///         This test fails on the current code and passes once `mintMystery` checks the subscription\n///         balance (LINK or native, matching `nativePayment`) before burning.\ncontract UnfundedSubscriptionBurnTest is Test {\n    MockBMC bmc;\n    VRFCoordinatorV2_5Mock coordinator;\n    BMCIlluminatiPass pass;\n    uint256 subId;\n\n    address owner = makeAddr(\"owner\");\n    address subOwner = makeAddr(\"subscriptionOwner\");\n    address alice = makeAddr(\"alice\");\n\n    function setUp() public {\n        bmc = new MockBMC();\n        coordinator = new VRFCoordinatorV2_5Mock(0.1 ether, 1e9, 4e15);\n        pass = new BMCIlluminatiPass(owner, address(bmc), address(coordinator));\n\n        vm.startPrank(subOwner);\n        subId = coordinator.createSubscription();\n        coordinator.addConsumer(subId, address(pass));\n        vm.stopPrank();\n        // Subscription deliberately left with zero LINK and zero native balance.\n\n        vm.startPrank(owner);\n        pass.setGenesisImagesCID(\"bafytest\");\n        pass.setVRFConfig(subId, keccak256(\"lane\"), 500_000, 3, false);\n        vm.stopPrank();\n\n        for (uint256 i = 0; i < 40; i++) {\n            address who = address(uint160(0x7000 + i));\n            bmc.mint(who, 1_000_000e18);\n            vm.startPrank(who);\n            bmc.approve(address(pass), 1_000_000e18);\n            pass.mintGenesis();\n            vm.stopPrank();\n        }\n    }\n\n    function test_MysteryMintFailsClosedWhenSubscriptionCannotPayForFulfilment() public {\n        (uint96 balance, uint96 nativeBalance,,,) = coordinator.getSubscription(subId);\n        assertEq(balance, 0);\n        assertEq(nativeBalance, 0);\n\n        bmc.mint(alice, 350_000e18);\n        uint256 supplyBefore = bmc.totalSupply();\n        vm.startPrank(alice);\n        bmc.approve(address(pass), 350_000e18);\n        // Expected: revert (fail closed, nothing burned). Actual: succeeds, 350,000 BMC destroyed,\n        // token 41 minted with a request the coordinator can never pay for.\n        (bool ok,) = address(pass).call(abi.encodeCall(pass.mintMystery, ()));\n        vm.stopPrank();\n\n        assertFalse(ok, \"mintMystery accepted a burn although the VRF subscription has no balance\");\n        assertEq(bmc.balanceOf(alice), 350_000e18, \"buyer's BMC must be untouched\");\n        assertEq(bmc.totalSupply(), supplyBefore, \"nothing may be burned\");\n        assertEq(pass.totalMinted(), 40, \"no pending token may be minted\");\n    }\n}","reproduction":"State: Chainlink VRFCoordinatorV2_5Mock, subscription created by subOwner, pass added as consumer, subscription balance 0 LINK / 0 native. Owner setVRFConfig(subId, lane, 500000, 3, false). Phase 1 complete. alice holds 350,000e18 BMC and approves the pass. alice calls mintMystery(). Expected: revert, alice's balance 350,000e18, totalMinted 40, nothing burned. Actual: call succeeds, 350,000e18 BMC destroyed (totalSupply drops), token 41 minted AwaitingRandomness, request accepted; coordinator.fulfillRandomWords reverts InsufficientBalance (shown by the repo's own test) and per Chainlink docs the request expires after 24h with no way to re-request. Run: forge test --match-path test/scratch/UnfundedSubscriptionBurn.t.sol -> FAIL 'mintMystery accepted a burn although the VRF subscription has no balance'.","severity":"medium","snippet":"        _burnExact(msg.sender, MYSTERY_PRICE);","title":"mintMystery burns 350,000 BMC and opens a VRF request even when the subscription cannot pay for fulfilment"},{"citation":"resolved","description":"The contract inherits OpenZeppelin Ownable, not Ownable2Step. transferOwnership(address) takes effect immediately and renounceOwnership() is available. The owner's only live power after launch is setVRFConfig, but that power is load-bearing for the whole phase 2: the request parameters (subscription id, key hash, callback gas, payment mode) must be changeable because they depend on Chainlink-side state that the contract does not control. Mainnet currently exposes exactly three proving key hashes (read from the coordinator: 0xc6bf..., 0x8077..., 0x3fd2...); Chainlink has retired lanes before (v2 -> v2.5 migration), and a subscription can be cancelled by its owner once no request is pending. If the owner key is lost (typo in transferOwnership, compromised key renouncing, or an early renounce as README O-5 contemplates) and the configured key hash or subscription later becomes invalid, VRF_COORDINATOR.requestRandomWords reverts (InvalidKeyHash / InvalidSubscription / InvalidConsumer) inside every mintMystery, so phase 2 stops permanently short of 500 tokens, the remaining Genesis/Standard outcomes are never minted, and no one can repair it. This is a documented trust assumption (README 'Renouncing freezes the VRF config forever'); it is reported because the audit brief asks for review of transferOwnership/renounceOwnership and the consequence (collection cannot complete) is not spelled out. Fix preserving design: inherit Ownable2Step (two-step handover, pendingOwner must call acceptOwnership), and either override renounceOwnership to revert or document that it must only be called after totalMinted == MAX_SUPPLY. Using a multisig as owner (README O-5) is complementary.","line":40,"path":"src/BMCIlluminatiPass.sol","reproduction":"State: owner = EOA O, totalMinted = 200, VRF config keyHash = K. O calls transferOwnership(0x000...dead) (one keystroke error, accepted immediately, OwnershipTransferred emitted). Later Chainlink retires lane K (s_provingKeys[K].exists == false). Any buyer's mintMystery() now reverts inside requestRandomWords with InvalidKeyHash; setVRFConfig(newSub, K2, ...) from O reverts OwnableUnauthorizedAccount(O); no other function can change vrfConfig. Expected: ownership handover requires acceptance by the new owner, or the frozen state is impossible. Actual: 300 mystery tokens (including all 37 mystery Genesis outcomes) can never be minted; totalMinted is stuck at 200 forever.","severity":"low","snippet":"contract BMCIlluminatiPass is ERC721, Ownable, ReentrancyGuard {","title":"Single-step Ownable: a mistyped transferOwnership or renounceOwnership permanently freezes the VRF config and can strand the sale below 500"},{"citation":"resolved","description":"Measured on this code (forge, solc 0.8.26, optimizer 200): a first permissionless finalizeReveals(1) consumes ~131k execution gas (133,696 including the test's external call), not ~85k; the ~85k figure appears to include end-of-transaction SSTORE refunds, which do not reduce gasleft() during execution. Consequences: (a) README step 4 says a 500k callback 'lets each callback finalize up to 4 reveals'; with six words stored out of order a 500k callback finalizes 3 (batch = (465k-30k)/110k = 3). (b) At the minimum permitted config MIN_CALLBACK_GAS_LIMIT = 150_000, gasleft after storing the word is ~115k, so batch = 0 and the callback never finalizes anything; every reveal then depends on an unpaid, un-incentivised finalizeReveals call (website button). (c) The inner self-call still succeeds in every band because batch sizing uses 110k while the 63/64 forwarding rule leaves ~128-137k per reveal, but the margin at the 175k-250k callback band is under 10%. Nothing is lost on failure (the try/catch keeps the word), so this is informational. Suggested: re-measure and set GAS_PER_REVEAL to ~140k, raise MIN_CALLBACK_GAS_LIMIT so a callback can always store + finalize at least one token, and correct the README number.","line":94,"path":"src/BMCIlluminatiPass.sol","reproduction":"Setup: phase 1 complete, setVRFConfig(..., callbackGasLimit = 500_000, ...). Mint six mystery tokens 41..46; fulfil 46,45,44,43,42 first (all stored, revealedCount == 0), then fulfil 41 with the request's own 500k gas limit. Expected per README: revealedCount == 4 (41..44). Actual: revealedCount == 3. Second case: fulfilWithGas(req, word, 150_000) then measure finalizeReveals(1) from a fresh caller: ~133k gas; with gasleft ~115k after the store, batch == 0 and the callback finalizes nothing. Scratch probe used: test/scratch/GasProbe.t.sol (console output 'revealed by one 500k callback: 3', 'finalizeReveals(1) gas: 133696').","severity":"info","snippet":"    /// @dev Conservative gas budget per reveal (measured ~85k worst case) and reserve for the return path.\n    uint256 public constant GAS_PER_REVEAL = 110_000;","title":"Callback gas budget constants and README batch claim do not match measured reveal cost"}],"hash":"30ab8d75cc752ad56d60cc9ec24350f7e7e75c6d3ee374f1a6420f18adb74f26","nodeId":"34a3fecc-26e5-4709-8f96-3c8519d5db29","outcome":"completed","summary":"The review is complete. The findings file is in place at the repository root, and no tracked file was changed.\n\n**What I did**\n\n- Mapped every state-changing entry point (two owner setters, two public mints, the coordinator-only callback, the permissionless finalizer, inherited Ownable and ERC-721 functions) and checked each against the Access Control, Trust Gap and Asymmetry guides.\n- Built and ran the suite: 43 tests pass. I also ran the repository's own mainnet fork test against a public RPC and it passes, so the live BMC token's `burnFrom` does reduce supply and balance by exactly the price.\n- Verified external facts the permission model relies on: the mainnet coordinator's config (min 3 / max 200 confirmations, 2.5M max gas, three live key hashes) matches the contract's bounds, and Chainlink's docs state underfunded requests expire after 24 hours and failed callbacks are never retried.\n\n**Findings written to `.imd-findings.json`**\n\n- **High.** Strict in-order finalization means one VRF request that is never fulfilled freezes every later reveal permanently, even when those tokens' random words are already stored. The README describes only the affected token staying pending. Proof test fails on current code and shows tokens 42 and 43 stuck behind an unfulfilled 41.\n- **Medium.** `mintMystery` burns 350,000 BMC and opens a request even when the subscription has zero LINK and zero native balance. Chainlink accepts such requests and drops them after 24 hours, and there is no re-request path, so the buyer's payment is lost. Proof test fails on current code using Chainlink's own mock coordinator.\n- **Low.** Single-step `Ownable` rather than `Ownable2Step`. A mistyped transfer or an early renounce freezes `setVRFConfig`, and if Chainlink later retires the configured key hash the sale stops short of 500 with no repair path.\n- **Info.** The per-reveal gas constant's comment claims about 85k but execution gas is about 131k. A 500k callback finalizes 3 reveals, not the 4 the README states, and at the 150k minimum the callback never finalizes anything.\n\n**Not found in the assigned area**\n\nNo missing modifier, no permission bypass, no confused deputy on the BMC allowance, no owner mint, withdrawal or upgrade path, and the coordinator-only check on the callback is immutable. The owner's two setters behave as documented.\n\n**Unresolved risks for the requester**\n\nThe subscription owner is a separate privileged role whose inaction can trigger the high finding. I could not fetch the coordinator's verified source to confirm `cancelSubscription` is blocked while requests are pending, so that remains an assumption. The review covered the permission model in depth; it is not a complete independent audit of the whole system.","treeHash":null,"usage":{"cachedInputTokens":1922185,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":46190,"runtime":"claude","turns":45,"wallClockMs":697354}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"c97fd6a73e3737dc","findings":[{"citation":"resolved","description":"finalizeReveals() only advances in strict mint order: the loop stops at the first token that is not in RandomnessStored. A token whose VRF request is never fulfilled therefore stays AwaitingRandomness and every later token stays Pending as well, even when their own random words have already been delivered and stored. The contract has no way to open a replacement request (no timeout, no re-request, nothing the owner, the subscription owner or Chainlink can call), so the freeze is permanent. The NatSpec (lines 36-39) and README (lines 61-67) assume this cannot happen because 'Chainlink VRF v2.5 requests remain pending while the subscription is unfunded and are fulfilled once it is funded again'. That is only true for 24 hours: Chainlink's VRF v2.5 subscription documentation (docs.chain.link/vrf/v2-5/overview/subscription) states 'If your balance is below the minimum, your requests remain pending for up to 24 hours before they expire' and pending requests process after funding only 'as long as they have not expired'. Other ways a request is never fulfilled: the key hash is not a registered lane (the live coordinator accepts such requests and never fulfils them, see the separate finding), a lane is retired by Chainlink while in use, or the v2.5 coordinator is deprecated. Economic impact: every buyer after the stuck token has irreversibly burned 350,000 BMC for an NFT that can never be revealed, the advertised 77 Genesis / 423 Standard composition can never be completed, and the stuck tokens trade as perpetual 'Pending'. The blast radius is the whole remainder of the collection, not the single 'affected token' the README describes. An unprivileged amplifier exists: a buyer minting many mystery tokens in a short window (there is no rate limit) consumes the subscription balance; the requests that cannot be paid for expire if the subscription owner does not top up within 24 hours. Fix (keeps in-order finalization and the no-reroll guarantee): record the request block per token and add a permissionless `rerequestRandomness(uint256 tokenId)` that, for a token still AwaitingRandomness whose request is older than a fixed timeout (e.g. 2 days, longer than Chainlink's 24h expiry so no legitimate fulfilment can still be in flight), opens a new VRF request for the same token and maps the new request id to it. Keep the old request id mapped as well so that whichever word is delivered first is the one used; later deliveries are already ignored by the AwaitingRandomness check in rawFulfillRandomWords, so nobody gains a reroll. Also correct the NatSpec/README wording about expiry. Size note: the runtime is 23,488 bytes (1,088 under EIP-170). A reference implementation of this fix (requestBlockOf mapping, 14,400-block timeout, rerequestRandomness with nonReentrant and the same request parameters) compiled to 24,342 bytes, 234 bytes under the limit, and made the attached test pass; shorten the Genesis description strings if more headroom is needed.","line":321,"path":"src/BMCIlluminatiPass.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {BMCIlluminatiPass} from \"src/BMCIlluminatiPass.sol\";\n\n/// @dev Minimal BMC stand-in with OpenZeppelin ERC20Burnable `burnFrom` semantics (spend allowance, destroy).\ncontract ProofBMC {\n    uint8 public constant decimals = 18;\n    uint256 public totalSupply;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\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 from, uint256 amount) external {\n        allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount;\n        totalSupply -= amount;\n    }\n}\n\n/// @dev VRF v2.5 coordinator stand-in. Accepts every `requestRandomWords` call (like the real\n///      coordinator, which does not validate the key hash) and lets the test choose which requests\n///      are ever delivered. A request that is never delivered models an expired request: Chainlink\n///      drops requests that stay unfunded for 24h, and never fulfils requests for an unknown key hash.\ncontract ProofCoordinator {\n    uint256 public nextId = 1000;\n    mapping(uint256 => address) public consumerOf;\n\n    fallback(bytes calldata) external returns (bytes memory) {\n        uint256 id = nextId++;\n        consumerOf[id] = msg.sender;\n        return abi.encode(id);\n    }\n\n    function deliver(uint256 id, uint256 word) external returns (bool ok) {\n        uint256[] memory words = new uint256[](1);\n        words[0] = word;\n        (ok,) = consumerOf[id].call(abi.encodeWithSignature(\"rawFulfillRandomWords(uint256,uint256[])\", id, words));\n    }\n}\n\n/// Finding: one VRF request that is never fulfilled freezes the reveal of every later mystery token\n/// forever. `finalizeReveals` only advances in strict mint order and the contract has no way to open a\n/// replacement request, so token 42's stored randomness can never be finalized while token 41 waits.\n///\n/// Expected after the fix: a permissionless, timeout-gated\n/// `rerequestRandomness(uint256 tokenId)` that, for a token still in `AwaitingRandomness` whose request\n/// is older than the timeout, opens a new VRF request for the same token (the old request id may stay\n/// mapped; the first word delivered wins, later deliveries are ignored as today). With that in place\n/// token 41 gets a word, is finalized, and token 42 follows.\ncontract HeadOfLineBlockingProof is Test {\n    ProofBMC bmc;\n    ProofCoordinator coord;\n    BMCIlluminatiPass pass;\n\n    address owner = address(0x0A);\n    address alice = address(0xA11CE);\n    address bob = address(0xB0B);\n\n    uint256 constant GENESIS_PRICE = 1_000_000e18;\n    uint256 constant MYSTERY_PRICE = 350_000e18;\n\n    function setUp() public {\n        bmc = new ProofBMC();\n        coord = new ProofCoordinator();\n        pass = new BMCIlluminatiPass(owner, address(bmc), address(coord));\n        vm.startPrank(owner);\n        pass.setGenesisImagesCID(\"bafyproof\");\n        pass.setVRFConfig(1, keccak256(\"lane\"), 500_000, 3, false);\n        vm.stopPrank();\n        for (uint256 i = 0; i < 40; i++) {\n            address who = address(uint160(0x1000 + i));\n            bmc.mint(who, GENESIS_PRICE);\n            vm.startPrank(who);\n            bmc.approve(address(pass), GENESIS_PRICE);\n            pass.mintGenesis();\n            vm.stopPrank();\n        }\n    }\n\n    function _mintMystery(address who) internal returns (uint256 id, uint256 req) {\n        bmc.mint(who, MYSTERY_PRICE);\n        vm.startPrank(who);\n        bmc.approve(address(pass), MYSTERY_PRICE);\n        (id, req) = pass.mintMystery();\n        vm.stopPrank();\n    }\n\n    function test_StuckRequestMustNotFreezeLaterRevealsForever() public {\n        (uint256 a, uint256 reqA) = _mintMystery(alice); // token 41, request never delivered\n        (uint256 b, uint256 reqB) = _mintMystery(bob); // token 42\n        assertEq(a, 41);\n        assertEq(b, 42);\n\n        // Token 42's randomness arrives; token 41's never does (expired / invalid lane).\n        assertTrue(coord.deliver(reqB, 0xB0B));\n        assertEq(uint8(pass.revealStatusOf(b)), uint8(BMCIlluminatiPass.RevealStatus.RandomnessStored));\n\n        // A long time passes. Nothing anyone can call moves token 42 forward.\n        vm.warp(block.timestamp + 30 days);\n        vm.roll(block.number + 216_000);\n        assertEq(pass.finalizeReveals(100), 0, \"42 blocked behind 41\");\n        assertEq(uint8(pass.tierOf(b)), uint8(BMCIlluminatiPass.Tier.Pending));\n        assertEq(pass.pendingReveals(), 2);\n\n        // Recovery path expected by the fix: anyone re-requests randomness for the stuck token.\n        (bool ok,) = address(pass).call(abi.encodeWithSignature(\"rerequestRandomness(uint256)\", a));\n        assertTrue(ok, \"no recovery path: token 41 is stuck forever and every later token with it\");\n\n        uint256 reqA2 = pass.requestIdOf(a);\n        assertTrue(reqA2 != reqA, \"a fresh VRF request must be opened for token 41\");\n        assertTrue(coord.deliver(reqA2, 0xA11CE));\n\n        // Both tokens are finalized (inline by the callback batch or via finalizeReveals).\n        pass.finalizeReveals(10);\n        assertEq(uint8(pass.revealStatusOf(a)), uint8(BMCIlluminatiPass.RevealStatus.Revealed));\n        assertEq(uint8(pass.revealStatusOf(b)), uint8(BMCIlluminatiPass.RevealStatus.Revealed));\n        assertEq(pass.pendingReveals(), 0);\n        assertEq(pass.revealedCount(), 2);\n    }\n}","reproduction":"State: phase 1 complete (40 Genesis minted), VRF configured. 1) alice: approve 350_000e18 BMC, mintMystery() -> token 41, request A. 2) bob: approve 350_000e18 BMC, mintMystery() -> token 42, request B. 3) Coordinator delivers request B: rawFulfillRandomWords(B, [word]) -> revealStatusOf(42) == RandomnessStored, tierOf(42) == Pending. 4) Request A is never delivered (expired after 24h unfunded, or unknown key hash). Warp 30 days, roll 216,000 blocks. 5) Call finalizeReveals(100) from any account. Expected: a path exists for 42 (and 41) to be revealed eventually. Actual: finalizeReveals returns 0, pendingReveals() == 2, tierOf(42) stays Pending forever; no function in the contract can change this. Live evidence for the trigger: on a mainnet fork at block 26174013, a subscription created on the real coordinator 0xD7f86b4b8Cae7D942340FF628F82735b7a20893a with the pass as consumer accepted mintMystery() with keyHash keccak256('500 gwei lane'): pendingRequestExists(subId) == true and alice's 350,000 BMC were burned, although no VRF node will ever fulfil a request for that key hash. The attached test test/scratch/HeadOfLineBlocking.t.sol fails on the current code at 'no recovery path' and passes once a permissionless, timeout-gated rerequestRandomness(uint256) is added as described.","severity":"high","snippet":"        while (done < maxCount && tokenId <= totalMinted && _revealStatus[tokenId] == RevealStatus.RandomnessStored) {","title":"One unfulfilled VRF request freezes every later mystery reveal forever; no re-request or recovery path"},{"citation":"resolved","description":"The only validation on keyHash is `keyHash != 0`. The Ethereum mainnet VRF v2.5 coordinator (verified source, requestRandomWords) explicitly does not validate the key hash: 'Note we do not check whether the keyHash is valid to save gas. The consequence for users is that they can send requests for invalid keyHashes which will simply not be fulfilled.' So a wrong lane (typo, a Sepolia lane pasted on mainnet, a retired lane, or a placeholder like the keccak256('500 gwei lane') values used throughout the test suite) does not make mintMystery revert. Every mystery mint succeeds, burns 350,000 BMC, mints a Pending NFT and opens a request that is never fulfilled. Combined with strict in-order finalization this freezes all later reveals permanently even after the owner corrects the config (see the high finding). The README's statement 'A bad config only blocks new mystery mints until corrected' (line 74) is therefore wrong for the key-hash field, and the owner has no way to tell from the chain that the lane is dead until the first reveal never arrives. The registered mainnet lanes at the time of review are s_provingKeyHashes(0..2) = 0xc6bf2e7b88e5cfbb4946ff23af846494ae1f3c65270b79ee7876c9aa99d3d45f, 0x8077df514608a09f83e4e8d300645594e5d7234665448ba83f51a50f842bd3d9, 0x3fd2fec10d06ee8f65e7f2e95f5c56511359ece3f33960ad8a866ae24a8ff10b. Fix: in setVRFConfig, verify the key hash is registered on the coordinator (iterate `s_provingKeyHashes(i)` through a small interface until the call reverts, revert InvalidVRFConfig if not found), or at minimum require the deployer runbook to read the lane from the coordinator and add the recovery path from the high finding so that a mistake is not permanent. Also replace the placeholder lane strings in README step 4 with the instruction to read the lane from vrf.chain.link / s_provingKeyHashes.","line":204,"path":"src/BMCIlluminatiPass.sol","reproduction":"Mainnet fork (block 26174013), real coordinator 0xD7f86b4b8Cae7D942340FF628F82735b7a20893a and real BMC: deploy the pass, setGenesisImagesCID, mint 40 Genesis; as subscription owner createSubscription(), addConsumer(subId, pass), fundSubscriptionWithNative{value: 5 ether}(subId). Owner calls setVRFConfig(subId, keccak256('500 gwei lane'), 500000, 3, true) -> accepted (only the zero check exists). alice approves 350_000e18 BMC and calls mintMystery(). Expected (per README line 74): revert, no burn. Actual: returns tokenId 41, BMC.balanceOf(alice) == 0, revealStatusOf(41) == AwaitingRandomness, coordinator.pendingRequestExists(subId) == true; the request is for an unregistered key hash and will never be fulfilled, and every token minted afterwards is blocked behind token 41.","severity":"medium","snippet":"            subscriptionId == 0 || keyHash == bytes32(0) || callbackGasLimit < MIN_CALLBACK_GAS_LIMIT","title":"setVRFConfig accepts any non-zero key hash; the live coordinator accepts and never fulfils such requests while 350,000 BMC per mint is burned"},{"citation":"resolved","description":"The contract uses OpenZeppelin's single-step Ownable. Both owner powers are prerequisites for any mint: setGenesisImagesCID gates mintGenesis and mintMystery, setVRFConfig gates mintMystery. If the owner calls renounceOwnership() (or transferOwnership to an address they do not control, which Ownable accepts in one step) before the CID is set, no token can ever be minted and the deployment is dead; if it happens after the CID but before a working VRF config, the 460 mystery mints can never happen and the VRF config can never be corrected (relevant to the key-hash finding). The README (line 76) only notes that renouncing 'freezes the VRF config forever'. Fix: inherit Ownable2Step, and override renounceOwnership to revert unless genesisCIDLocked && vrfConfig.configured (or disable it entirely), so that the two configuration steps cannot be locked out by a single mistaken call.","line":40,"path":"src/BMCIlluminatiPass.sol","reproduction":"1) Deploy BMCIlluminatiPass(owner, BMC, coordinator). 2) owner calls renounceOwnership() (or transferOwnership(0x000...dead)). 3) Anyone (including the former owner) calls setGenesisImagesCID('bafy...') -> reverts OwnableUnauthorizedAccount. 4) mintGenesis() -> reverts GenesisCIDNotSet; mintMystery() -> reverts GenesisCIDNotSet. Expected: the configuration steps cannot be irrecoverably skipped by one call. Actual: the contract is permanently unusable and must be redeployed.","severity":"low","snippet":"contract BMCIlluminatiPass is ERC721, Ownable, ReentrancyGuard {","title":"Single-step Ownable: renounceOwnership or a mistyped transferOwnership before configuration permanently disables minting"},{"citation":"resolved","description":"README line 132 gives `forge build && forge test && forge fmt --check` as the test command and docs/TEST-RESULTS.md records 43 passing tests. The fork test added afterwards hard-requires chain id 1 instead of skipping, so plain `forge test` now reports 1 failing test (44 total), and the file is not formatted (leading blank line and a space before the SPDX header, wrapped call), so `forge fmt --check` exits 1. A reviewer following the README cannot distinguish this expected failure from a real regression, and CI only passes because the workflow excludes the contract with --no-match-contract. Fix: start the test with `if (block.chainid != 1) { vm.skip(true); return; }` (or read the RPC via vm.envOr and skip when absent), run `forge fmt`, and refresh README/TEST-RESULTS to 44 tests with the fork test listed separately.","line":27,"path":"test/BMCMainnetFork.t.sol","reproduction":"Fresh clone, no RPC: `forge test` -> 'Ran 3 test suites ... 43 tests passed, 1 failed' with [FAIL: Ethereum mainnet fork required] test_RealBMCGenesisMint(). `forge fmt --check test/BMCMainnetFork.t.sol` -> exit code 1 with a diff. Expected per README/TEST-RESULTS: all tests pass and formatting is clean. With --fork-url on an Ethereum mainnet RPC the test passes (verified at block ~26174000).","severity":"low","snippet":"        require(block.chainid == 1, \"Ethereum mainnet fork required\");","title":"Documented verification commands fail: `forge test` and `forge fmt --check` fail on the fork test, and the recorded test count is stale"},{"citation":"resolved","description":"Two information effects of the design, not permission bypasses, that affect who pays 350,000 BMC for what. (a) `remaining` depends only on revealedCount, so for a token whose word is stored but which is blocked behind an earlier AwaitingRandomness token, r is already fixed and `_slotValue(r)` is the outcome unless an earlier blocked draw lands on the same slot. The word is also emitted in RandomnessStored. Anyone reading the chain knows the near-certain tier of a 'Pending' token; a marketplace buyer who does not will overpay for a known-Standard token or be front-run on a known-Genesis one. (b) The inventory is public (remainingMysteryGenesis / remainingMysteryStandard). Once all 37 mystery Genesis are drawn, every remaining mystery mint is a guaranteed Standard at the same 350,000 BMC; conversely if 36 are drawn with one slot left, the final mint is a guaranteed Genesis for 350,000 BMC versus the 1,000,000 BMC Genesis price, and the last mints become a gas auction. The website already shows provisional odds, so this is informational: document both effects for buyers, consider not emitting the raw word, and consider having the website refuse to show 'odds' once they are 0% or 100%.","line":334,"path":"src/BMCIlluminatiPass.sol","reproduction":"(a) Phase 1 done; mint 41 (request never fulfilled) and 42; deliver 42's word w. Compute r = keccak256(abi.encode(w, 42)) % 460 off-chain; if r >= 37 the token is a Standard with artwork r-36 as soon as 41 is finalized, while tokenURI(42) still says 'Pending Reveal' and tierOf(42) == Pending. (b) State revealedCount == 459, mysteryGenesisRevealed == 36, totalMinted == 499: currentPrice() == 350_000e18 and the next mintMystery() is certain to draw the last Genesis (remaining == 1, picked == the only slot left, which is < 37).","severity":"info","snippet":"        uint256 r = uint256(keccak256(abi.encode(word, tokenId))) % remaining;","title":"Public without-replacement pool makes late and head-of-line-blocked mystery outcomes knowable before finalization"},{"citation":"resolved","description":"The coordinator is immutable by design (SECURITY-REVIEW F-3), which removes the owner's reroll-by-proxy power. Two consequences should be stated as accepted risks. (1) The live VRFCoordinatorV2_5.migrate(subId, newCoordinator) calls IVRFMigratableConsumerV2Plus(consumer).setCoordinator(newCoordinator) on every consumer; the pass has no such function, so the subscription owner cannot migrate a subscription that lists the pass as a consumer (the call reverts) and must removeConsumer first, which in turn requires no pending requests. (2) If Chainlink retires the v2.5 coordinator or all its lanes during the sale, mintMystery either reverts (no consumer / no subscription) or keeps burning BMC for requests nobody fulfils (see the key-hash finding), and any request outstanding at that moment freezes all later reveals (see the high finding). The README mentions decommissioning only for 'affected tokens'. Recommendation: keep the immutable coordinator, but document these outcomes, keep the sale window short relative to Chainlink's deprecation notice periods, and rely on the recovery path from the high finding.","line":99,"path":"src/BMCIlluminatiPass.sol","reproduction":"On the mainnet coordinator source (verified on Sourcify), migrate(): `for (...) IVRFMigratableConsumerV2Plus(consumers[i]).setCoordinator(newCoordinator);`. With the pass registered as a consumer, migrate reverts because the pass has no setCoordinator(address) selector. Expected by a subscription owner: migration proceeds; actual: revert until the pass is removed as consumer.","severity":"info","snippet":"    IVRFCoordinatorV2Plus public immutable VRF_COORDINATOR;","title":"Immutable coordinator with no setCoordinator: subscription migrate() reverts and a coordinator deprecation strands all future mystery mints"},{"citation":"resolved","description":"run() always uses MAINNET_BMC and MAINNET_VRF_COORDINATOR but never checks block.chainid == 1. Run against any other RPC (a testnet rehearsal, a wrong --rpc-url), it deploys a contract pointing at addresses with no code there: setGenesisImagesCID succeeds, then every mintGenesis reverts inside BMC.decimals() (call to an address without code) and the deployment is useless, while the deployer may believe the rehearsal represents mainnet. Fix: `require(block.chainid == 1, \"mainnet only\");` in run(), and have deploy() check `cfg.bmc.code.length > 0 && cfg.vrfCoordinator.code.length > 0`.","line":35,"path":"script/Deploy.s.sol","reproduction":"OWNER=<addr> forge script script/Deploy.s.sol:DeployBMCIlluminatiPass --rpc-url <sepolia> --broadcast: succeeds; on the resulting contract mintGenesis() reverts with an empty revert from the decimals() staticcall to 0x38224239f2BDae357D47cb864BC533d7f72dF961 (no code on that chain). Expected: the script refuses to run off mainnet.","severity":"info","snippet":"        pass = deploy(mainnetConfig(owner));","title":"Deploy script has no chain-id guard around the hard-coded mainnet addresses"},{"citation":"resolved","description":"Three statements the operator will rely on do not match measured or documented behaviour. (1) Line 105: with callbackGasLimit 500,000 the callback has about 465k gas left after storing the word, and batch = (465k - 30k) / 110k = 3, so at most 3 reveals run inline (measured with Chainlink's VRFCoordinatorV2_5Mock: 3 revealed inline at 500k, 4 only at 2,500,000). (2) Lines 64-67 and NatSpec lines 38-39: Chainlink documents that requests on an underfunded subscription 'remain pending for up to 24 hours before they expire'; they are not fulfilled whenever funding arrives. (3) Line 19 and docs/TEST-RESULTS.md: the repository has 44 tests (43 unit/mock + 1 fork). Fix the wording; the first two directly affect how much subscription balance the operator must keep and how urgently a funding gap must be closed.","line":105,"path":"README.md","reproduction":"Chainlink mock, phase 1 done, setVRFConfig(sub, lane, 500000, 3, false): mint tokens 41..46, deliver 46,45,44,43,42 (all stored), then deliver 41 -> revealedCount() == 3 (expected 4 per README). Same with 2,500,000 -> revealedCount() == 4. `grep -c 'function test' test/*.sol` -> 44.","severity":"info","snippet":"   500k callback gas lets each callback finalize up to 4 reveals. `nativePayment=true` if funded with ETH.","title":"README operational claims are inaccurate: unfunded requests expire after 24h, a 500k callback finalizes 3 reveals not 4, 44 tests not 43"}],"hash":"4f4afc30897ec6dc0358cf0ebadfce48f136d2964149b7248582296f20c4fad8","nodeId":"d3971a2b-f4ce-413f-a030-1fd5e4c47053","outcome":"completed","summary":"The review is complete. The findings file is written and validated, and the working tree contains nothing else (test/scratch is gitignored, as the task intends).\n\n## Outcome\n\nEight findings are in `.imd-findings.json`, one High with an attached Foundry proof, one Medium, two Low, four Informational. No Critical. The core mint, burn and draw logic is sound; the real risk sits in the VRF failure path.\n\n**High. One unfulfilled VRF request freezes every later reveal forever.** Reveals finalize in strict mint order and there is no re-request path. If token 41's request is never fulfilled, token 42's stored randomness can never be finalized, nor can any later token. Chainlink's own docs state that requests on an underfunded subscription expire after 24 hours, which contradicts the README and NatSpec claim that they stay pending until funded. The proof test fails on the current code at \"no recovery path\" and passes on a patched copy with a permissionless, timeout-gated re-request. That patch fits the EIP-170 limit with 234 bytes to spare.\n\n**Medium. A wrong key hash burns BMC for requests nobody will fulfil.** The contract only checks the key hash is non-zero. I verified on a mainnet fork that the live coordinator accepted a request with `keccak256(\"500 gwei lane\")` from the pass and burned the buyer's 350,000 BMC. The README's claim that a bad config only blocks new mints is false for this field.\n\n**Low.** Single-step Ownable lets one mistaken renounce or transfer before configuration brick minting permanently. The README's documented `forge test && forge fmt --check` command fails because the fork test hard-requires chain id 1 instead of skipping, and is unformatted.\n\n**Info.** Pending outcomes are knowable before finalization from stored words and the public inventory. The immutable coordinator makes `migrate()` revert for the subscription owner. The deploy script has no chain-id guard. Three README operational claims are inaccurate.\n\n## What checked out\n\n- Live BMC token is a plain OpenZeppelin ERC20Burnable with no owner, pause or fee. The fork test passes against it.\n- Live coordinator config matches the contract's bounds. Request ABI accepted on a fork with a valid lane.\n- Fisher-Yates draw is correct. Full 500-mint simulation yields 77 Genesis, 423 Standard, no duplicates.\n- Callback stores the word at the 150k minimum gas and finalizes 4 inline at 2.5M.\n- Pinned IPFS directory lists 77 files. Two samples (genesis-01, genesis-77) are byte-identical to the repo.\n- Runtime size is 23,488 bytes.\n\n## Unverified\n\nI could not confirm all 77 pinned images byte-for-byte, only two samples plus the directory listing. The 24-hour expiry is from Chainlink's documentation, not observed on-chain, since VRF proofs cannot be forged on a fork. Not a complete independent audit; Slither and extended fuzzing were not run.","treeHash":null,"usage":{"cachedInputTokens":2361517,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":75095,"runtime":"claude","turns":49,"wallClockMs":1293290}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"5ded77c3c883b7b0","findings":[{"citation":"resolved","description":"Boundary at the external call VRF_COORDINATOR.requestRandomWords (line 253) and at the finalization loop (line 321). The calling code assumes that a request the coordinator accepts will eventually be fulfilled, and finalizeReveals requires the head token (id 40 + revealedCount + 1) to be in RandomnessStored before any later token can be drawn. Actual coordinator behaviour, verified on an Ethereum mainnet fork at block 26174011 against 0xD7f86b4b8Cae7D942340FF628F82735b7a20893a: requestRandomWords does not validate keyHash. With keyHash = keccak256(\"not a registered proving key\") (s_provingKeys(keyHash).exists == false; only 0xc6bf2e7b..., 0x8077df51..., 0x3fd2fec1... are registered) the request was accepted, a requestId was returned, mintMystery succeeded, 350,000 BMC were burned and pendingRequestExists(subId) became true; such a request can never be fulfilled, and the subscription cannot even be cancelled while it is pending (cancelSubscription reverts with PendingRequestExists). setVRFConfig only checks keyHash != 0 (line 204), so a mistyped 32-byte key hash passes both guards. Once token 41 (or any token N) is stuck in AwaitingRandomness, the loop condition at line 321 is false for it forever, so tokens N+1..500 can never be finalized even after their own words are delivered and stored; the owner correcting setVRFConfig does not help because there is no re-request, and the pool of 37 Genesis / 423 Standard outcomes is frozen. README line 74 states 'A bad config only blocks new mystery mints until corrected' and README lines 61-67 describe the pending state as per-token; both are wrong: the blast radius is every later minter (up to 459 x 350,000 = 160,650,000 BMC burned for tokens that can never reveal). The same amplification applies to any other cause of a single unfulfillable request (Chainlink deregistering a proving key with requests outstanding, a lane going offline). Fix, preserving the agreed design: (1) in setVRFConfig staticcall s_provingKeys(keyHash) on the coordinator (present on the mainnet v2.5 coordinator) and revert InvalidVRFConfig when exists == false, which closes the misconfiguration path; (2) add a permissionless rerequestRandomness(tokenId) allowed only when _revealStatus[tokenId] == AwaitingRandomness and the original request is older than a long stale period (e.g. 50,000 blocks, so a mapping tokenId => requestBlock is needed), which issues a new request with the current config and maps the new requestId to the token while keeping the old mapping; the existing status check at line 298 already makes the first delivery win and the second a no-op, so no reroll is introduced. Update README lines 61-67 and 74 and the NatSpec at lines 36-39 to describe the collection-wide effect.","line":321,"path":"src/BMCIlluminatiPass.sol","reproduction":"Fork repro (mainnet, block 26174011, run with --fork-url): deploy BMCIlluminatiPass(owner, 0x38224239f2BDae357D47cb864BC533d7f72dF961, 0xD7f86b4b8Cae7D942340FF628F82735b7a20893a); as subOwner call coordinator.createSubscription() then addConsumer(subId, pass); as owner call setGenesisImagesCID(cid) and setVRFConfig(subId, keccak256(\"not a registered proving key\"), 500000, 3, false) -> both succeed; mint the 40 Genesis with deal()'d BMC; as 0xA11CE approve 350000e18 and call mintMystery(). Expected (README line 74): the mint is blocked until the config is corrected. Actual: mintMystery returns (41, 105665143416517196059945461372034980649344288029231122531284040351937517655670), balanceOf(0xA11CE) == 0, coordinator.pendingRequestExists(subId) == true, cancelSubscription(subId, subOwner) reverts. Local repro (MockVRFCoordinator): after phase 1, mintMystery three times -> tokens 41, 42, 43; never fulfil 41's request; fulfil 42 and 43 -> revealStatusOf(42) == revealStatusOf(43) == RandomnessStored; vm.roll(+1,000,000), vm.warp(+365 days); finalizeReveals(100) returns 0; owner calls setVRFConfig(43, keccak256(\"fixed lane\"), 500000, 3, false); finalizeReveals(100) still returns 0; tierOf(42) == tierOf(43) == Pending; revealStatusOf(41) == AwaitingRandomness. Expected: tokens whose randomness is stored can be revealed, or at least the owner can recover; actual: no path exists, every later token stays Pending forever.","severity":"medium","snippet":"        while (done < maxCount && tokenId <= totalMinted && _revealStatus[tokenId] == RevealStatus.RandomnessStored) {","title":"One VRF request that is never fulfilled permanently freezes every later reveal (strict in-order finalization has no recovery path), and the coordinator accepts unregistered key hashes so the documente"},{"citation":"resolved","description":"Audit scope item 4 asks to confirm the generator cannot accidentally overwrite the approved premium Genesis files. It can: with no argument the output directory resolves to artwork/genesis (the approved files), os.makedirs(..., exist_ok=True) on line 197 accepts the existing directory, and img.save(...) on line 201 overwrites each of the 77 PNGs without any existence check or confirmation. The README (line 133) even instructs running it with that directory. The 'byte-identical' claim depends on the installed Pillow version: PNG encoder output with optimize=True and the default-font text rendering of the label differ across Pillow releases, so a re-run on another machine can silently replace the approved artwork with different bytes, which would change the directory CID if the upload happens afterwards. Fix: default to a separate output directory (e.g. artwork/generated), refuse to write when the target file exists unless --force is passed, and record the sha256 of the 77 approved files in the repository so an upload can be checked against them before setGenesisImagesCID.","line":196,"path":"artwork/tools/generate_genesis.py","reproduction":"State: artwork/genesis contains the 77 approved PNGs. Input: python3 artwork/tools/generate_genesis.py (no argument) on a machine with a different Pillow version than the one used for the approved files. Expected: the approved files are untouched (or the tool refuses). Actual: out == artwork/genesis, makedirs succeeds because exist_ok=True, and all 77 files are overwritten by img.save on line 201.","severity":"low","snippet":"    out = sys.argv[1] if len(sys.argv) > 1 else os.path.join(os.path.dirname(__file__), \"..\", \"genesis\")","title":"Artwork generator writes into the approved Genesis directory by default and overwrites genesis-01.png .. genesis-77.png"},{"citation":"resolved","description":"rawFulfillRandomWords sizes the inline batch as (gasleft() - 30,000) / 110,000, capped at 4 (lines 307-311). Storing the word costs about 32,300 gas, so with a 500,000 callback the sample is about 467,000 and the batch is (467,000 - 30,000) / 110,000 = 3. Measured with the project's MockVRFCoordinator (which forwards exactly callbackGasLimit): inline reveals step to 1 at 174k, 2 at 284k, 3 at 394k and 4 only from 504k callback gas; at exactly 500,000 the callback reveals 3. The true inline cost per reveal is about 83k gas, so the 110k budget is conservative and the batch never runs out of gas (verified across a 160k-620k sweep), but the operational recommendation is off by one: an owner following step 4 of the README gets 3 reveals per callback and one more stored token per burst left for permissionless finalizeReveals. Fix: recommend 550,000 (or any value >= 504,000) in README line 105, or lower GAS_PER_REVEAL to a value that still covers the measured ~100k first-reveal cost (e.g. 105,000 would give 4 at 500k while keeping margin).","line":105,"path":"README.md","reproduction":"After phase 1, setVRFConfig(42, keyHash, 500000, 3, false); mintMystery six times (41..46); deliver words for 42..46 with 95,000 gas so they are stored only; deliver 41's word with the request's own 500,000 gas limit. Expected (README): revealedCount() == 4. Actual: revealedCount() == 3; with a 504,000 limit it is 4.","severity":"info","snippet":"   500k callback gas lets each callback finalize up to 4 reveals. `nativePayment=true` if funded with ETH.","title":"README overstates the inline reveal batch: 500k callback gas finalizes at most 3 reveals, not 4"},{"citation":"resolved","description":"The guard checks two deltas but the error always carries supplyDelta. When the token destroys exactly `amount` of supply but not from `payer` (for example a burnFrom that burns `amount` from a treasury account and only debits the payer a fee), supplyDelta == amount and balanceDelta != amount, so the revert data reads BurnMismatch(1000000e18, 1000000e18), which claims the burn matched. Separately, lines 278-279 compute supplyBefore - totalSupply() and balanceBefore - balanceOf(payer) with checked arithmetic; if either value grows during burnFrom (a token that mints a rebate or hooks a transfer back to the payer) the call reverts with Panic(0x11) rather than BurnMismatch. Both paths fail closed, so there is no loss; the issue is only that the diagnostics are misleading, which matters because this error is the contract's sole evidence about an unexpected BMC behaviour. Fix: compute the deltas in a way that cannot underflow (compare before/after values directly) and emit both deltas: revert BurnMismatch(amount, supplyDelta, balanceDelta), or two distinct errors.","line":280,"path":"src/BMCIlluminatiPass.sol","reproduction":"Deploy with a mock whose burnFrom(account, amount) does _spendAllowance(account, msg.sender, amount), _burn(treasury, amount) and _transfer(account, treasury, amount / 100). Fund payer with 1,000,000e18, approve, call mintGenesis(). Expected: a revert that identifies the payer-balance mismatch (balance fell by 10,000e18 instead of 1,000,000e18). Actual: revert BurnMismatch(1000000000000000000000000, 1000000000000000000000000), i.e. both reported values are equal. Second input: a mock whose burnFrom mints amount*2 to account before burning amount; expected BurnMismatch, actual Panic(0x11) from line 279.","severity":"info","snippet":"        if (supplyDelta != amount || balanceDelta != amount) revert BurnMismatch(amount, supplyDelta);","title":"BurnMismatch reports the supply delta even when the payer-balance delta is the mismatching operand, and a supply or balance increase during burnFrom panics instead of reverting with BurnMismatch"}],"hash":"586baab44c0568fe76e85c949de81c5e04b02b312e54cbeb674db236ca3407f5","nodeId":"27df359c-72d1-492d-9b1f-41bb78a07100","outcome":"completed","summary":"Review complete. The findings file is written, validated against the tree, and every claim in it was reproduced by a test.\n\n**Outcome.** The arithmetic in the contracts is sound. I found one medium-severity robustness defect at the VRF boundary, one low outside the math area, and two informational items. No tracked files were changed. Scratch tests live under test/scratch, which is gitignored.\n\n**What I verified as correct**\n- Supply and price constants: 40 + 460 = 500, 40 + 37 = 77, 37 + 423 = 460, and 40 × 1,000,000 + 460 × 350,000 = 201,000,000 BMC.\n- The virtual Fisher-Yates draw. The swap-with-last encoding, the r == remaining−1 edge, and full mint-out coverage all hold, including a crafted word that picks the last slot on the first draw.\n- Standard art mapping. 1327 is coprime with 5400, so the 423 ids give 423 distinct trait tuples; the mixed-radix decode is a bijection.\n- All narrowing casts, subtractions and modulo operations are bounded by construction; there is no division by zero and no underflow path reachable from user input.\n- Gas budget: storing a word needs about 32k gas, one inline reveal about 83k, so the 150k minimum and the 110k-per-reveal budget are conservative. A 160k to 620k sweep showed no out-of-gas dips at any batch threshold.\n- Live mainnet reads: BMC has 18 decimals and about 992.7M supply; coordinator bounds match the contract's constants; the project's real-token fork test passes, so burnFrom follows OpenZeppelin semantics.\n\n**Findings written to .imd-findings.json**\n- **Medium.** One VRF request that is never fulfilled freezes every later reveal forever, because finalization is strictly in mint order and there is no re-request. On a mainnet fork the live coordinator accepted a request with an unregistered key hash, burned the 350k BMC, and left an uncancellable pending request. The README's claim that a bad config only blocks new mints until corrected is false. Fix suggested: validate the key hash against the coordinator's proving-key registry in setVRFConfig, and add a stale-request permissionless re-request that keeps the first delivery as the only one used.\n- **Low.** The artwork generator writes into the approved Genesis directory by default and overwrites all 77 files, contrary to scope item 4.\n- **Info.** README recommends 500k callback gas for \"up to 4 reveals\"; the formula yields 3 at 500k and 4 only from about 504k.\n- **Info.** BurnMismatch always reports the supply delta even when the balance delta is the mismatching operand, and a supply increase during burnFrom panics instead of reverting with the custom error. Both fail closed.\n\n**Unresolved risks for the requester.** The reveal-queue blocking is the item that needs a design decision before mainnet. Slither and Mythril were not run. The fork test exercised only one Genesis mint against the real token.","treeHash":null,"usage":{"cachedInputTokens":2123463,"inputTokens":610,"model":"claude-fable-5-1","outputTokens":59041,"runtime":"claude","turns":47,"wallClockMs":929400}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"357c46e3781993d4","findings":[{"citation":"resolved","description":"finalizeReveals() only ever inspects token 40 + revealedCount + 1 and stops as soon as that token is not in RandomnessStored. A token whose VRF request is never fulfilled therefore blocks the finalization of every later token forever, even when those later tokens' random words have already been delivered and stored on-chain. No other code path can finalize a stored word (the callback itself only calls this.finalizeReveals), and there is no re-request, skip or owner intervention. A single never-fulfilled request is a realistic state on mainnet: (a) Chainlink's VRF v2.5 docs state that when the subscription balance is below the minimum, requests 'remain pending for up to 24 hours before they expire', and the v2.5 coordinator accepts requests without checking the balance (the repo's own test_UnfundedSubscriptionKeepsTokenPendingUntilFunded shows that), so a 24-hour funding lapse during the 460-mint phase expires every request made in that window; an unprivileged user can amplify this by burst-minting to push the subscription below its minimum; (b) the coordinator does not validate keyHash at request time (VRFCoordinatorV2_5.sol line 284: 'Note we do not check whether the keyHash is valid to save gas. ... requests for invalid keyHashes which will simply not be fulfilled'), so one mint made under a mistyped setVRFConfig produces a request that will never be fulfilled; (c) Chainlink deregistering a lane's proving key. Impact: every holder who minted after the stuck token has burned 350,000 BMC for an NFT whose tier and artwork can never be assigned (worst case 459 tokens, 160,650,000 BMC), while their randomness sits unused in _randomWord. Fix (preserves the design goal that no caller can choose the draw order): finalize in randomness-delivery order instead of mint order. In rawFulfillRandomWords push tokenId onto a storage queue (uint256[] _deliveryQueue) after storing the word, and make finalizeReveals pop from _deliveryQueue starting at a _queueHead cursor; _finalize already derives `remaining` from revealedCount, so the without-replacement draw is unchanged. Delivery order is chosen only by Chainlink nodes, which the project already accepts (F-10). A stuck token then affects only itself (see the companion finding on the missing re-request path).","line":320,"path":"src/BMCIlluminatiPass.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {BMCIlluminatiPass} from \"src/BMCIlluminatiPass.sol\";\n\n/// @dev Minimal 18-decimal token with OpenZeppelin-style burnFrom semantics (spend allowance, destroy).\ncontract PocBMC {\n    string public name = \"BMC Illuminati\";\n    string public symbol = \"BMC\";\n    uint256 public totalSupply;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function decimals() external pure returns (uint8) {\n        return 18;\n    }\n\n    function mint(address to, uint256 amount) external {\n        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, \"insufficient allowance\");\n        allowance[account][msg.sender] = a - amount;\n        require(balanceOf[account] >= amount, \"insufficient balance\");\n        balanceOf[account] -= amount;\n        totalSupply -= amount;\n    }\n}\n\ninterface IRawFulfill {\n    function rawFulfillRandomWords(uint256 requestId, uint256[] calldata randomWords) external;\n}\n\n/// @dev Minimal VRF v2.5 coordinator stand-in. The struct layout matches VRFV2PlusClient.RandomWordsRequest so\n///      the selector of requestRandomWords((bytes32,uint256,uint16,uint32,uint32,bytes)) is identical.\ncontract PocCoordinator {\n    struct RandomWordsRequest {\n        bytes32 keyHash;\n        uint256 subId;\n        uint16 requestConfirmations;\n        uint32 callbackGasLimit;\n        uint32 numWords;\n        bytes extraArgs;\n    }\n\n    uint256 public nextRequestId = 1000;\n    mapping(uint256 => address) public consumerOf;\n    mapping(uint256 => uint32) public gasOf;\n\n    function requestRandomWords(RandomWordsRequest calldata req) external returns (uint256 id) {\n        id = nextRequestId++;\n        consumerOf[id] = msg.sender;\n        gasOf[id] = req.callbackGasLimit;\n    }\n\n    /// @dev Deliver one word with the request's own callback gas limit, like the real coordinator.\n    function fulfill(uint256 requestId, uint256 word) external returns (bool ok) {\n        uint256[] memory words = new uint256[](1);\n        words[0] = word;\n        (ok,) = consumerOf[requestId].call{gas: gasOf[requestId]}(\n            abi.encodeCall(IRawFulfill.rawFulfillRandomWords, (requestId, words))\n        );\n    }\n}\n\n/// @title Head-of-line blocking of reveals by one unfulfilled VRF request\n/// @notice `finalizeReveals` only ever looks at token `40 + revealedCount + 1`. If that token's VRF request is\n///         never fulfilled (expired/dropped request, invalid key hash lane, lane decommissioned), every later\n///         mystery token whose randomness HAS been delivered stays `RandomnessStored` forever: no path exists\n///         to reveal it. This test fails on the current code and passes once reveals are processed in\n///         randomness-delivery order (or any order independent of a still-pending earlier token).\ncontract HeadOfLineBlockingTest is Test {\n    PocBMC bmc;\n    PocCoordinator vrf;\n    BMCIlluminatiPass pass;\n\n    address owner = makeAddr(\"owner\");\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n    address carol = makeAddr(\"carol\");\n\n    uint256 constant GENESIS_PRICE = 1_000_000e18;\n    uint256 constant MYSTERY_PRICE = 350_000e18;\n\n    function setUp() public {\n        bmc = new PocBMC();\n        vrf = new PocCoordinator();\n        pass = new BMCIlluminatiPass(owner, address(bmc), address(vrf));\n        vm.startPrank(owner);\n        pass.setGenesisImagesCID(\"bafybeihfihwwpev4gkqjuqawgtptsv3ynzsokclo6odsolvoa65aqaac2u\");\n        pass.setVRFConfig(1, keccak256(\"lane\"), 500_000, 3, false);\n        vm.stopPrank();\n        for (uint256 i = 0; i < 40; i++) {\n            address who = address(uint160(0x1000 + i));\n            bmc.mint(who, GENESIS_PRICE);\n            vm.startPrank(who);\n            bmc.approve(address(pass), GENESIS_PRICE);\n            pass.mintGenesis();\n            vm.stopPrank();\n        }\n    }\n\n    function _mintMystery(address who) internal returns (uint256 id, uint256 req) {\n        bmc.mint(who, MYSTERY_PRICE);\n        vm.startPrank(who);\n        bmc.approve(address(pass), MYSTERY_PRICE);\n        (id, req) = pass.mintMystery();\n        vm.stopPrank();\n    }\n\n    function test_DeliveredRandomnessIsRevealedEvenIfAnEarlierRequestNeverCompletes() public {\n        (uint256 a,) = _mintMystery(alice); // token 41: its VRF request is never fulfilled\n        (uint256 b, uint256 rb) = _mintMystery(bob); // token 42\n        (uint256 c, uint256 rc) = _mintMystery(carol); // token 43\n        assertEq(a, 41);\n        assertEq(b, 42);\n        assertEq(c, 43);\n\n        // The coordinator delivers randomness for 42 and 43 (callback succeeds, words are stored).\n        assertTrue(vrf.fulfill(rb, uint256(keccak256(\"word-42\"))));\n        assertTrue(vrf.fulfill(rc, uint256(keccak256(\"word-43\"))));\n\n        // Give any keeper every chance to finalize.\n        pass.finalizeReveals(type(uint256).max);\n        vm.warp(block.timestamp + 365 days);\n        vm.roll(block.number + 2_600_000);\n        pass.finalizeReveals(type(uint256).max);\n\n        // Bob and Carol paid 350,000 BMC each and their randomness is on-chain, so their tokens must be revealed.\n        assertEq(\n            uint8(pass.revealStatusOf(b)),\n            uint8(BMCIlluminatiPass.RevealStatus.Revealed),\n            \"token 42 stuck behind token 41 whose request was never fulfilled\"\n        );\n        assertEq(\n            uint8(pass.revealStatusOf(c)),\n            uint8(BMCIlluminatiPass.RevealStatus.Revealed),\n            \"token 43 stuck behind token 41 whose request was never fulfilled\"\n        );\n        assertTrue(pass.tierOf(b) != BMCIlluminatiPass.Tier.Pending, \"token 42 has no tier\");\n        assertTrue(pass.tierOf(c) != BMCIlluminatiPass.Tier.Pending, \"token 43 has no tier\");\n        // Token 41 legitimately stays pending: its own randomness never arrived.\n        assertEq(uint8(pass.revealStatusOf(a)), uint8(BMCIlluminatiPass.RevealStatus.AwaitingRandomness));\n    }\n}","reproduction":"State: phase 1 complete (40 Genesis minted), CID and VRF config set. Alice mints token 41, Bob mints token 42, Carol mints token 43 (350,000 BMC burned each). The coordinator delivers the random words for requests 42 and 43 (callback succeeds; revealStatusOf(42) == revealStatusOf(43) == RandomnessStored) but the request for 41 is never fulfilled (expired after 24h unfunded, or invalid keyHash). Anyone calls finalizeReveals(type(uint256).max), now and a year later. Expected: tokens 42 and 43 are Revealed with a tier and artwork, since their randomness is on-chain and paid for. Actual: finalizeReveals returns 0, revealStatusOf(42) and (43) stay RandomnessStored (2), tierOf stays Pending (0), forever. Run: forge test --match-path test/scratch/HeadOfLineBlocking.t.sol -> FAIL 'token 42 stuck behind token 41 whose request was never fulfilled: 2 != 3'. With a delivery-order queue replacing the mint-order cursor, the same test passes (verified against a patched copy).","severity":"high","snippet":"        uint256 tokenId = GENESIS_PHASE_SUPPLY + revealedCount + 1;\n        while (done < maxCount && tokenId <= totalMinted && _revealStatus[tokenId] == RevealStatus.RandomnessStored) {","title":"finalizeReveals processes strictly in mint order: one never-fulfilled VRF request permanently freezes the reveal of every later mystery token"},{"citation":"resolved","description":"The contract design (natspec lines 36-39, README 'Unavailable randomness', docs/SECURITY-REVIEW.md F-2/F-10, and the integration test test_UnfundedSubscriptionKeepsTokenPendingUntilFunded) rests on the assumption that a VRF v2.5 request stays pending indefinitely until the subscription is funded. Chainlink's VRF v2.5 subscription documentation says: 'If your balance is below the minimum, your requests remain pending for up to 24 hours before they expire. After you add sufficient LINK or native tokens to a subscription, pending requests automatically process as long as they have not expired.' The mainnet coordinator (0xD7f8...893a, VRFCoordinatorV2_5) accepts requests without a balance check and bills at fulfillment, so mintMystery succeeds, burns 350,000 BMC and mints the NFT while the subscription is already too low; if nobody tops it up within 24 hours the request expires and the token is AwaitingRandomness forever. mintMystery stores exactly one requestId per token and rawFulfillRandomWords only accepts that id; there is no function to open a new request for a token still AwaitingRandomness, so the state is irrecoverable and the minter's burned BMC bought a permanently 'Pending Reveal' NFT. The same irrecoverable state is reached through an invalid keyHash (see the setVRFConfig finding) or a deregistered lane. An unprivileged amplifier exists: because requests are accepted regardless of balance, anyone can burst-mint (e.g. 50 mystery mints in a few blocks, each adding a request the subscription cannot pay for) and push the subscription under its minimum; every request in that burst, including other users', then expires unless the operator reacts within 24 hours. Fix: add a permissionless `rerequestRandomness(uint256 tokenId)` allowed only while _revealStatus[tokenId] == AwaitingRandomness and at least N blocks (~24h, e.g. 7200) after the token's last request block; record the new requestId in tokenIdOfRequest as well (keep the old mapping) so whichever delivery arrives first is stored and later ones are ignored by the existing idempotency check. Because the first delivered word wins and no word can be rejected, this adds no reroll ability. Correct the natspec, README and SECURITY-REVIEW text, and replace the integration test's assumption with one that models expiry.","line":38,"path":"src/BMCIlluminatiPass.sol","reproduction":"State: phase 1 complete; subscription S created with the pass as consumer but balance below Chainlink's minimum (or drained by a burst of mints). Alice approves 350,000 BMC and calls mintMystery(): succeeds, BMC totalSupply drops by 350,000e18, token 41 minted, request R emitted (the coordinator does not check the balance, as test_UnfundedSubscriptionKeepsTokenPendingUntilFunded demonstrates). 24 hours pass without funding. Per Chainlink documentation R expires and will never be fulfilled. Expected per README: 'A pending token stays pending until its original request is fulfilled ... fulfils it after funding, which anyone can do' -> funding later reveals the token. Actual: funding later does nothing; revealStatusOf(41) == AwaitingRandomness permanently, tierOf(41) == Pending, tokenURI(41) is the '?' placeholder forever, and the contract exposes no function that can create a replacement request (grep: requestRandomWords is called only in mintMystery). With the current mint-order finalization, tokens 42..500 are frozen as well.","severity":"high","snippet":"///  Chainlink VRF v2.5 requests remain pending while the subscription is unfunded and are fulfilled\n///  once it is funded again, which anyone may do.","title":"No recovery path for an expired VRF request: Chainlink v2.5 drops unfunded requests after 24 hours, so a pending token can never be revealed although the contract and README state the opposite"},{"citation":"resolved","description":"The only validation of keyHash is != bytes32(0). The mainnet VRFCoordinatorV2_5 explicitly skips key hash validation in requestRandomWords (verified source line 284-286: 'Note we do not check whether the keyHash is valid to save gas. The consequence for users is that they can send requests for invalid keyHashes which will simply not be fulfilled'). README line 74 states that a bad VRF config 'only blocks new mystery mints until corrected'; that is true for a wrong subscription id or a non-consumer (the request reverts and the burn rolls back, as test_NotAConsumerReverts shows) but false for the keyHash: the request is accepted, 350,000 BMC is burned per mint, the NFT is minted pending and is never fulfilled, with no recovery path. A realistic trigger is pasting a VRF v2 key hash or a Sepolia lane (e.g. 0x787d74caea10b2b357790d5b5247c2f63d1d91572a9846f780606e4d953677ae, for which the mainnet coordinator returns s_provingKeys(...) = (false, 0)) instead of one of the three mainnet lanes (0x8077df51..., 0x3fd2fec1..., 0xc6bf2e7b..., all confirmed on-chain with s_provingKeys = (true, maxGas)). This is an owner-trust power, reported as requested under 'consequences of owner-controlled VRF configuration'; the defect is that the contract and documentation promise a fail-closed behaviour that the code does not have. Fix: in setVRFConfig query the coordinator: `(bool exists,) = IVRFCoordinatorProvingKeys(address(VRF_COORDINATOR)).s_provingKeys(keyHash); if (!exists) revert InvalidVRFConfig();` (the getter is `mapping(bytes32 => ProvingKey) public s_provingKeys` returning (bool exists, uint64 maxGas) on the mainnet coordinator), and fix the README sentence.","line":204,"path":"src/BMCIlluminatiPass.sol","reproduction":"State: phase 1 complete, subscription funded and pass added as consumer. Owner calls setVRFConfig(subId, 0x787d74caea10b2b357790d5b5247c2f63d1d91572a9846f780606e4d953677ae, 500000, 3, false): accepted (passes every check on lines 203-207). Alice approves and calls mintMystery(): on the real coordinator requestRandomWords returns a requestId (no keyHash check), 350,000 BMC is burned, token 41 is minted AwaitingRandomness. Expected per README: 'A bad config only blocks new mystery mints until corrected' i.e. mintMystery reverts and no BMC is burned. Actual: the mint succeeds, the BMC is destroyed and no VRF node will ever fulfil the request; after the owner corrects the config the already-minted token stays pending forever (and, with mint-order finalization, blocks all later tokens). On-chain check performed: cast call 0xD7f8...893a 's_provingKeys(bytes32)(bool,uint64)' 0x787d...77ae -> false 0.","severity":"medium","snippet":"            subscriptionId == 0 || keyHash == bytes32(0) || callbackGasLimit < MIN_CALLBACK_GAS_LIMIT","title":"setVRFConfig accepts any non-zero keyHash; the coordinator does not validate it, so a wrong lane burns users' BMC for mints that are never fulfilled, contrary to the README claim that a bad config onl"},{"citation":"resolved","description":"The fork test hard-requires chain id 1 instead of skipping or creating a fork from an environment variable. Running the suite as README ('forge build && forge test') and docs/TEST-RESULTS.md describe therefore ends with 1 failing test out of 44, not the recorded 43 passing; CI only works because .github/workflows/main.yml excludes the contract by name and re-runs it with a secret RPC. A reviewer or the fixer of any finding who runs `forge test` sees a red suite unrelated to their change. The test itself is valuable: run against a live mainnet fork it passes (verified here with --fork-url https://ethereum-rpc.publicnode.com: real BMC burnFrom destroys exactly 1,000,000e18, totalSupply drops accordingly). Fix: at the top of the test use `string memory url = vm.envOr(\"MAINNET_RPC_URL\", string(\"\")); if (bytes(url).length == 0) { vm.skip(true); return; } vm.createSelectFork(url);` and update README/TEST-RESULTS to 44 tests (one fork-gated).","line":27,"path":"test/BMCMainnetFork.t.sol","reproduction":"Run `forge test` in the repository root with no --fork-url. Expected (README, docs/TEST-RESULTS.md): all tests pass. Actual: '[FAIL: Ethereum mainnet fork required] test_RealBMCGenesisMint() (gas: 372)' and 'Encountered a total of 1 failing tests, 43 tests succeeded'.","severity":"low","snippet":"        require(block.chainid == 1, \"Ethereum mainnet fork required\");","title":"test/BMCMainnetFork.t.sol fails under plain `forge test` (no fork guard), so the documented `forge test` command and the '43 tests' count are wrong"},{"citation":"resolved","description":"Scope item 4 asks to confirm the generator cannot accidentally overwrite the approved premium Genesis files. It can: main() defaults `out` to artwork/genesis (line 196), creates it with exist_ok=True and img.save() overwrites each of the 77 files without comparing against the committed bytes or asking for confirmation. The docstring claims byte-identical output, but the pipeline uses Pillow's default bitmap font for the plate label (d.text with no font argument), GaussianBlur and zlib-optimized PNG encoding, all of which have changed output across Pillow releases; a regeneration with a different Pillow therefore replaces the approved files with visually similar but byte-different PNGs, changing the IPFS directory CID that setGenesisImagesCID locks forever. Determinism could not be verified in this environment (Pillow is not installed and pip is unavailable), so the claim is reported as unverified. Fix: make the script refuse to write into an existing non-empty directory unless --force is given, and/or compare the SHA-256 of each rendered image with a committed artwork/genesis/SHA256SUMS and exit non-zero on mismatch; pin the Pillow version in a requirements file.","line":201,"path":"artwork/tools/generate_genesis.py","reproduction":"Run `python3 artwork/tools/generate_genesis.py` (no argument) from the repository root with any Pillow version installed. Expected: the approved artwork/genesis/genesis-01.png .. 77.png (sha256 of genesis-01.png today: b35a60c9a4fb8227ca6dd4874dca5e9d3ef7948ac0ccdb7b770851e0ae11c406) are left untouched or the script refuses to overwrite. Actual: all 77 files are rewritten unconditionally (lines 196-201); whether the bytes match the approved files depends on the installed Pillow release.","severity":"info","snippet":"        img.save(os.path.join(out, f\"genesis-{n:02d}.png\"), \"PNG\", optimize=True)","title":"generate_genesis.py silently overwrites the approved artwork/genesis PNGs by default, with no hash check; byte-identical regeneration depends on the Pillow version"}],"hash":"727f743b1fa2c31c860207fedbc072d074b41808fb063eb3f16a15db212d3847","nodeId":"18a126a6-a775-448e-97d6-5be34db667a7","outcome":"completed","summary":"The review is complete. No tracked file was changed. The findings file at the repository root holds five substantiated findings, each with path, line, exact snippet, and a concrete reproduction. One High finding carries a self-contained Foundry proof that fails on the current code and passes on a patched copy.\n\n**What I verified**\n\n- `forge build` and the 43 unit tests pass. The 44th test, the mainnet fork test, fails under plain `forge test` and passes with a public mainnet RPC. On that fork the real BMC token burned exactly 1,000,000 BMC and totalSupply dropped by the same amount.\n- The live BMC token is verified source: a plain OpenZeppelin ERC20Burnable with 18 decimals, no owner, pause, fee, or upgrade path. The burn path and the balance and supply delta guards are sound.\n- The live VRF coordinator is VRFCoordinatorV2_5 with max gas 2,500,000 and 3 minimum confirmations, matching the contract's bounds. The three mainnet lanes exist on-chain. The coordinator source confirms key hashes are not validated at request time.\n- A 300-run fuzz over callback gas limits from 150k to 2.5M showed the callback never reverts, so no random word is lost at the coordinator. The virtual Fisher-Yates draw, outcome mapping, supply caps, prices, reentrancy guards, metadata paths, and owner surface all checked out.\n\n**Findings recorded**\n\n1. **High.** `finalizeReveals` only ever looks at the next token in mint order. One request that is never fulfilled permanently freezes the reveal of every later token even though their randomness is stored on-chain. Proof test attached and confirmed failing. Fix: process a delivery-order queue instead of a mint-order cursor, which keeps the no-caller-chooses-order property.\n2. **High.** Chainlink's own docs say underfunded requests expire after 24 hours, while the natspec, README, security review, and integration test assume they wait indefinitely. There is no re-request path, so an expired request leaves a token pending forever after 350,000 BMC was burned. A burst of mints is an unprivileged amplifier since requests are accepted regardless of balance.\n3. **Medium.** `setVRFConfig` only checks the key hash is non-zero. A wrong lane makes every later mint burn BMC for a request no node will ever fulfill, contradicting the README's claim that a bad config only blocks mints. Fix: query the coordinator's proving-key getter in the setter.\n4. **Low.** The fork test hard-requires chain id 1 instead of skipping, so the documented `forge test` command ends red and the \"43 tests\" count is wrong.\n5. **Info.** The artwork generator overwrites the approved Genesis PNGs by default with no hash check. Byte-identical regeneration could not be verified here because Pillow is unavailable, so that claim remains open.\n\n**Not covered.** Slither and Mythril were not run and a long fuzz campaign was not performed. The website JavaScript was read but not executed. These checks do not constitute a complete independent audit.","treeHash":null,"usage":{"cachedInputTokens":1772331,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":51520,"runtime":"claude","turns":56,"wallClockMs":803104}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"c9743cd9807c4c3a","findings":[{"citation":"resolved","description":"finalizeReveals() only ever inspects token GENESIS_PHASE_SUPPLY + revealedCount + 1 and stops at the first token that is not RandomnessStored (line 321). The callback itself only calls this.finalizeReveals, mintMystery stores exactly one requestId per token, and rawFulfillRandomWords accepts only that id. So a token whose request is never fulfilled stays AwaitingRandomness forever, and every later token stays Pending as well, even when its own random word has already been delivered and stored in _randomWord. No function can open a replacement request, skip the stuck token, or finalize a stored word out of order; the owner's setVRFConfig does not help because it only affects future requests.\n\nThe NatSpec (lines 36-39) and README (lines 61-67) assume this state cannot arise because 'Chainlink VRF v2.5 requests remain pending while the subscription is unfunded and are fulfilled once it is funded again'. That is only true for 24 hours: Chainlink's VRF v2.5 subscription documentation (docs.chain.link/vrf/v2-5/overview/subscription, fetched during this review) states 'If your balance is below the minimum, your requests remain pending for up to 24 hours before they expire' and that pending requests process after funding only 'as long as they have not expired'. The repository's own test test_UnfundedSubscriptionKeepsTokenPendingUntilFunded encodes the wrong assumption. Other ways a request is never fulfilled, all verified in this review: the mainnet coordinator accepts requests for an unregistered key hash and never fulfils them (see the separate medium finding; reproduced on a mainnet fork at block 26174090), a lane retired by Chainlink while requests are outstanding, or the v2.5 coordinator being decommissioned. An unprivileged buyer can also amplify the funding path: there is no rate limit, so burst-minting pushes the subscription below its minimum and every request in that window (other buyers' included) expires unless the operator tops up within 24 hours.\n\nImpact: every buyer after the stuck token has irreversibly burned 350,000 BMC for an NFT that can never be revealed (worst case 459 tokens, 160,650,000 BMC), the 37 mystery Genesis outcomes are never assigned, and the stuck tokens trade as perpetual 'Pending Reveal'. The README describes the pending state as affecting only 'the affected token'; the actual blast radius is the whole remainder of the collection.\n\nFix (preserves in-order finalization and the no-reroll guarantee): record the request block per mystery token and add a permissionless rerequestRandomness(uint256 tokenId) that, for a token still AwaitingRandomness whose request is older than a fixed timeout (e.g. 14,400 blocks, ~2 days, longer than Chainlink's 24h expiry so no legitimate fulfilment can still be in flight), opens a new VRF request with the current config and maps the new request id to the token while keeping the old mapping. The existing status check in rawFulfillRandomWords (line 298) already makes the first delivery win and later ones no-ops, so nobody gains a reroll. A reference implementation of exactly this (requestBlockOf mapping, 14,400-block timeout, same request parameters, nonReentrant) was applied during this review: the attached test passed and the runtime size was 24,277 bytes, 299 bytes under EIP-170. An alternative that also makes the attached test pass is finalizing in randomness-delivery order (a storage queue of delivered token ids) or a timeout-gated skip of a stuck head token; both change the 'mint order' property documented in F-1, so the re-request is the smaller design change. Whatever is chosen, correct the NatSpec at lines 36-39, README lines 61-67 and docs/SECURITY-REVIEW.md F-2/F-10, and replace test_UnfundedSubscriptionKeepsTokenPendingUntilFunded with a test that models expiry. Consider also the mint-time subscription balance check from the separate medium finding so requests are not opened against an unfundable subscription in the first place.","line":321,"path":"src/BMCIlluminatiPass.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {BMCIlluminatiPass} from \"src/BMCIlluminatiPass.sol\";\n\n/// @dev Minimal 18-decimal token with OpenZeppelin ERC20Burnable-style burnFrom (spend allowance, destroy).\ncontract PocBMC {\n    uint256 public totalSupply;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function decimals() external pure returns (uint8) {\n        return 18;\n    }\n\n    function mint(address to, uint256 amount) external {\n        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, \"insufficient allowance\");\n        allowance[account][msg.sender] = a - amount;\n        require(balanceOf[account] >= amount, \"insufficient balance\");\n        balanceOf[account] -= amount;\n        totalSupply -= amount;\n    }\n}\n\ninterface IRawFulfill {\n    function rawFulfillRandomWords(uint256 requestId, uint256[] calldata randomWords) external;\n}\n\n/// @dev VRF v2.5 coordinator stand-in: same requestRandomWords((bytes32,uint256,uint16,uint32,uint32,bytes))\n///      selector as the real coordinator; the test decides which request ids are ever delivered.\ncontract PocCoordinator {\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 lastRequestId = 1000;\n    mapping(uint256 => address) public consumerOf;\n    mapping(uint256 => uint32) public gasOf;\n    mapping(uint256 => bool) public delivered;\n\n    function requestRandomWords(RandomWordsRequest calldata req) external returns (uint256 id) {\n        id = ++lastRequestId;\n        consumerOf[id] = msg.sender;\n        gasOf[id] = req.callbackGasLimit;\n    }\n\n    /// @dev Deliver one word with the request's own callback gas limit, like the real coordinator.\n    function deliver(uint256 requestId, uint256 word) external returns (bool ok) {\n        require(consumerOf[requestId] != address(0), \"unknown request\");\n        require(!delivered[requestId], \"already delivered\");\n        delivered[requestId] = true;\n        uint256[] memory words = new uint256[](1);\n        words[0] = word;\n        (ok,) = consumerOf[requestId].call{gas: gasOf[requestId]}(\n            abi.encodeCall(IRawFulfill.rawFulfillRandomWords, (requestId, words))\n        );\n    }\n}\n\n/// @title One never-fulfilled VRF request freezes every later reveal forever\n/// @notice finalizeReveals only ever inspects token 40 + revealedCount + 1 and stops when it is not\n///         RandomnessStored. If that token's request is never fulfilled (expired after 24h on an\n///         underfunded subscription, unregistered key hash, retired lane), every later token whose word\n///         IS stored stays pending forever, and no function can re-request or skip. This test fails on\n///         the current code. It passes with any of the fixes that give the collection a way forward:\n///         a timeout-gated permissionless rerequestRandomness(uint256) for the stuck token, a\n///         timeout-gated skip of the stuck head token, or finalization in randomness-delivery order.\ncontract HeadOfLineBlockingTest is Test {\n    PocBMC bmc;\n    PocCoordinator vrf;\n    BMCIlluminatiPass pass;\n\n    address owner = makeAddr(\"owner\");\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n    address carol = makeAddr(\"carol\");\n\n    uint256 constant GENESIS_PRICE = 1_000_000e18;\n    uint256 constant MYSTERY_PRICE = 350_000e18;\n\n    function setUp() public {\n        bmc = new PocBMC();\n        vrf = new PocCoordinator();\n        pass = new BMCIlluminatiPass(owner, address(bmc), address(vrf));\n        vm.startPrank(owner);\n        pass.setGenesisImagesCID(\"bafybeihfihwwpev4gkqjuqawgtptsv3ynzsokclo6odsolvoa65aqaac2u\");\n        pass.setVRFConfig(1, keccak256(\"lane\"), 500_000, 3, false);\n        vm.stopPrank();\n        for (uint256 i = 0; i < 40; i++) {\n            address who = address(uint160(0x1000 + i));\n            bmc.mint(who, GENESIS_PRICE);\n            vm.startPrank(who);\n            bmc.approve(address(pass), GENESIS_PRICE);\n            pass.mintGenesis();\n            vm.stopPrank();\n        }\n    }\n\n    function _mintMystery(address who) internal returns (uint256 id, uint256 req) {\n        bmc.mint(who, MYSTERY_PRICE);\n        vm.startPrank(who);\n        bmc.approve(address(pass), MYSTERY_PRICE);\n        (id, req) = pass.mintMystery();\n        vm.stopPrank();\n    }\n\n    function test_StuckRequestMustNotFreezeLaterRevealsForever() public {\n        (uint256 a, uint256 reqA) = _mintMystery(alice); // token 41: its request is never delivered\n        (uint256 b, uint256 reqB) = _mintMystery(bob); // token 42\n        (uint256 c, uint256 reqC) = _mintMystery(carol); // token 43\n        assertEq(a, 41);\n        assertEq(b, 42);\n        assertEq(c, 43);\n\n        // The coordinator delivers 42 and 43 (callbacks succeed, words stored). 41's request never completes.\n        assertTrue(vrf.deliver(reqB, uint256(keccak256(\"word-42\"))));\n        assertTrue(vrf.deliver(reqC, uint256(keccak256(\"word-43\"))));\n        assertEq(uint8(pass.revealStatusOf(b)), uint8(BMCIlluminatiPass.RevealStatus.RandomnessStored));\n        assertEq(uint8(pass.revealStatusOf(c)), uint8(BMCIlluminatiPass.RevealStatus.RandomnessStored));\n\n        // A long time passes: far beyond Chainlink's 24h expiry and any reasonable recovery timeout.\n        vm.warp(block.timestamp + 30 days);\n        vm.roll(block.number + 216_000);\n\n        // Every path the fixed contract might offer is exercised:\n        // (1) permissionless finalization (delivery-order or skip-after-timeout fixes);\n        pass.finalizeReveals(type(uint256).max);\n        // (2) a permissionless re-request for the stuck token (re-request fix); if the contract opens a\n        //     fresh request, the coordinator delivers it and finalization is retried.\n        (bool rerequested,) = address(pass).call(abi.encodeWithSignature(\"rerequestRandomness(uint256)\", a));\n        if (rerequested) {\n            uint256 fresh = vrf.lastRequestId();\n            assertTrue(fresh != reqA, \"re-request must open a new VRF request\");\n            assertTrue(vrf.deliver(fresh, uint256(keccak256(\"word-41-again\"))));\n            pass.finalizeReveals(type(uint256).max);\n        }\n\n        // Bob and Carol each burned 350,000 BMC and their randomness is on-chain: their tokens must be revealable.\n        assertEq(\n            uint8(pass.revealStatusOf(b)),\n            uint8(BMCIlluminatiPass.RevealStatus.Revealed),\n            \"token 42 has stored randomness but is frozen forever behind never-fulfilled token 41\"\n        );\n        assertEq(\n            uint8(pass.revealStatusOf(c)),\n            uint8(BMCIlluminatiPass.RevealStatus.Revealed),\n            \"token 43 has stored randomness but is frozen forever behind never-fulfilled token 41\"\n        );\n        assertTrue(pass.tierOf(b) != BMCIlluminatiPass.Tier.Pending, \"token 42 has no tier\");\n        assertTrue(pass.tierOf(c) != BMCIlluminatiPass.Tier.Pending, \"token 43 has no tier\");\n        // Without-replacement accounting must still hold: every finalized token consumed exactly one slot.\n        assertEq(pass.revealedCount(), rerequested ? 3 : 2);\n    }\n}","reproduction":"State: phase 1 complete (40 Genesis minted), CID and VRF config set. 1) alice approves 350_000e18 BMC and calls mintMystery() -> token 41, request A. 2) bob -> token 42, request B. 3) carol -> token 43, request C. 4) The coordinator delivers B and C: revealStatusOf(42) == revealStatusOf(43) == RandomnessStored (2), tierOf == Pending. 5) Request A is never delivered (expired after 24h underfunded, unregistered key hash, retired lane). Warp 30 days, roll 216,000 blocks. 6) Anyone calls finalizeReveals(type(uint256).max); the owner calls setVRFConfig with a fresh subscription and lane; anyone calls a hypothetical rerequestRandomness(41). Expected: a path exists for 42 and 43 (and eventually 41) to be revealed. Actual: finalizeReveals returns 0, the rerequest selector does not exist, revealStatusOf(42) and (43) stay 2 forever, pendingReveals() == 3, and no function in the contract can change this. Run: forge test --match-path test/scratch/HeadOfLineBlocking.t.sol -> FAIL 'token 42 has stored randomness but is frozen forever behind never-fulfilled token 41: 2 != 3'. The same test passes with a timeout-gated permissionless rerequestRandomness(uint256) added (verified on a patched copy), or with delivery-order / skip-after-timeout finalization.","severity":"high","snippet":"        while (done < maxCount && tokenId <= totalMinted && _revealStatus[tokenId] == RevealStatus.RandomnessStored) {","title":"One never-fulfilled VRF request freezes every later mystery reveal forever: strict in-order finalization with no re-request, skip or recovery path"},{"citation":"resolved","description":"The only validation of keyHash is `keyHash != bytes32(0)`. The Ethereum mainnet VRFCoordinatorV2_5 (0xD7f86b4b8Cae7D942340FF628F82735b7a20893a, verified source read from Sourcify during this review) says in requestRandomWords: 'Note we do not check whether the keyHash is valid to save gas. The consequence for users is that they can send requests for invalid keyHashes which will simply not be fulfilled.' So a mistyped lane, a VRF v2 or Sepolia key hash pasted on mainnet, a retired lane, or a placeholder such as the keccak256('500 gwei lane') / keccak256('lane') values used throughout the repository's tests does not make mintMystery revert. Every mystery mint under such a config succeeds, burns 350,000 BMC, mints a Pending NFT and opens a request no VRF node will ever serve. The subscription cannot even be cancelled while that request is outstanding (cancelSubscription reverts with PendingRequestExists). README line 74 states 'A bad config only blocks new mystery mints until corrected': that is true for a wrong subscription id or a non-consumer (the request reverts and the burn rolls back, test_NotAConsumerReverts) but false for the key hash, and combined with strict in-order finalization (high finding) one such mint freezes all later reveals even after the owner corrects the config. This is an owner-trust power the brief asked to be reviewed; the defect is that the contract and documentation promise a fail-closed behaviour the code does not have, and the owner cannot tell from the chain that the lane is dead until the first reveal never arrives.\n\nRegistered mainnet lanes at review time (s_provingKeyHashes(0..2), read live at block 26174082): 0xc6bf2e7b88e5cfbb4946ff23af846494ae1f3c65270b79ee7876c9aa99d3d45f, 0x8077df514608a09f83e4e8d300645594e5d7234665448ba83f51a50f842bd3d9, 0x3fd2fec10d06ee8f65e7f2e95f5c56511359ece3f33960ad8a866ae24a8ff10b; s_provingKeys(keccak256('500 gwei lane')) and s_provingKeys(0x787d74caea10b2b357790d5b5247c2f63d1d91572a9846f780606e4d953677ae) both return (false, 0).\n\nFix: in setVRFConfig, staticcall `s_provingKeys(bytes32)` on the coordinator through a small interface (`(bool exists,) = IVRFProvingKeys(address(VRF_COORDINATOR)).s_provingKeys(keyHash); if (!exists) revert InvalidVRFConfig();`), or iterate s_provingKeyHashes(i) until it reverts and require a match. Add the recovery path from the high finding so that a lane retired after configuration is not permanent either, replace the placeholder lane wording in README step 4 with an instruction to read the lane from the coordinator, and fix README line 74.","line":204,"path":"src/BMCIlluminatiPass.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {BMCIlluminatiPass} from \"src/BMCIlluminatiPass.sol\";\n\n/// @dev Minimal 18-decimal token with OpenZeppelin ERC20Burnable-style burnFrom (spend allowance, destroy).\ncontract PocBMC {\n    uint256 public totalSupply;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function decimals() external pure returns (uint8) {\n        return 18;\n    }\n\n    function mint(address to, uint256 amount) external {\n        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, \"insufficient allowance\");\n        allowance[account][msg.sender] = a - amount;\n        require(balanceOf[account] >= amount, \"insufficient balance\");\n        balanceOf[account] -= amount;\n        totalSupply -= amount;\n    }\n}\n\n/// @dev Models the Ethereum mainnet VRFCoordinatorV2_5 (0xD7f86b4b8Cae7D942340FF628F82735b7a20893a):\n///      `s_provingKeys(bytes32)` returns (exists, maxGas) and `requestRandomWords` deliberately does NOT\n///      check the key hash (\"requests for invalid keyHashes which will simply not be fulfilled\").\ncontract PocCoordinator {\n    struct RandomWordsRequest {\n        bytes32 keyHash;\n        uint256 subId;\n        uint16 requestConfirmations;\n        uint32 callbackGasLimit;\n        uint32 numWords;\n        bytes extraArgs;\n    }\n\n    struct ProvingKey {\n        bool exists;\n        uint64 maxGas;\n    }\n\n    mapping(bytes32 => ProvingKey) public s_provingKeys;\n    bytes32[] public s_provingKeyHashes;\n    uint256 public lastRequestId = 1000;\n    mapping(uint256 => bytes32) public keyHashOfRequest;\n\n    constructor() {\n        bytes32 lane = 0xc6bf2e7b88e5cfbb4946ff23af846494ae1f3c65270b79ee7876c9aa99d3d45f;\n        s_provingKeys[lane] = ProvingKey({exists: true, maxGas: 1e12});\n        s_provingKeyHashes.push(lane);\n    }\n\n    function requestRandomWords(RandomWordsRequest calldata req) external returns (uint256 id) {\n        id = ++lastRequestId;\n        keyHashOfRequest[id] = req.keyHash;\n    }\n}\n\n/// @title setVRFConfig accepts a key hash the coordinator will never fulfil\n/// @notice The only check is `keyHash != 0`. The mainnet coordinator accepts requests for unregistered\n///         key hashes and never fulfils them, so every mystery mint under such a config burns 350,000 BMC\n///         for a token that stays pending forever. The README promises \"A bad config only blocks new\n///         mystery mints until corrected\", which this shows to be false for the key hash.\n///         Fails on the current code; passes once setVRFConfig verifies the key hash against the\n///         coordinator (s_provingKeys(keyHash).exists, or a scan of s_provingKeyHashes) and reverts\n///         InvalidVRFConfig otherwise.\ncontract KeyHashNotValidatedTest is Test {\n    PocBMC bmc;\n    PocCoordinator vrf;\n    BMCIlluminatiPass pass;\n    address owner = makeAddr(\"owner\");\n    address alice = makeAddr(\"alice\");\n\n    function setUp() public {\n        bmc = new PocBMC();\n        vrf = new PocCoordinator();\n        pass = new BMCIlluminatiPass(owner, address(bmc), address(vrf));\n        vm.prank(owner);\n        pass.setGenesisImagesCID(\"bafybeihfihwwpev4gkqjuqawgtptsv3ynzsokclo6odsolvoa65aqaac2u\");\n        for (uint256 i = 0; i < 40; i++) {\n            address who = address(uint160(0x1000 + i));\n            bmc.mint(who, 1_000_000e18);\n            vm.startPrank(who);\n            bmc.approve(address(pass), 1_000_000e18);\n            pass.mintGenesis();\n            vm.stopPrank();\n        }\n    }\n\n    function test_UnregisteredKeyHashIsRejectedBeforeAnyBMCIsBurned() public {\n        bytes32 badKey = keccak256(\"500 gwei lane\"); // the placeholder lane used across the repo's tests\n        (bool exists,) = vrf.s_provingKeys(badKey);\n        assertFalse(exists);\n\n        vm.prank(owner);\n        (bool accepted,) = address(pass).call(\n            abi.encodeCall(pass.setVRFConfig, (1, badKey, 500_000, 3, false))\n        );\n\n        if (accepted) {\n            // Current behaviour: the config is live and the next mystery mint burns BMC for a request\n            // that no VRF node will ever fulfil.\n            bmc.mint(alice, 350_000e18);\n            vm.startPrank(alice);\n            bmc.approve(address(pass), 350_000e18);\n            (uint256 id, uint256 req) = pass.mintMystery();\n            vm.stopPrank();\n            assertEq(id, 41);\n            assertEq(bmc.balanceOf(alice), 0, \"350,000 BMC burned\");\n            assertEq(vrf.keyHashOfRequest(req), badKey, \"request opened for a key hash no node serves\");\n        }\n        assertFalse(accepted, \"setVRFConfig accepted a key hash the coordinator does not serve\");\n\n        // A registered lane must still be accepted.\n        bytes32 goodKey = vrf.s_provingKeyHashes(0);\n        vm.prank(owner);\n        pass.setVRFConfig(1, goodKey, 500_000, 3, false);\n        (,, uint32 gasLimit,,, bool configured) = pass.vrfConfig();\n        assertTrue(configured);\n        assertEq(gasLimit, 500_000);\n    }\n}","reproduction":"Mainnet fork reproduction (block 26174090, --fork-url on an Ethereum mainnet RPC, test/scratch/ForkKeyHash.t.sol): deploy BMCIlluminatiPass(owner, 0x38224239f2BDae357D47cb864BC533d7f72dF961, 0xD7f86b4b8Cae7D942340FF628F82735b7a20893a); as subOwner createSubscription(), addConsumer(subId, pass), fundSubscriptionWithNative{value: 5 ether}(subId); owner setGenesisImagesCID(cid) and setVRFConfig(subId, keccak256('500 gwei lane'), 500000, 3, true) -> accepted; mint the 40 Genesis with deal()'d BMC; alice approves 350_000e18 and calls mintMystery(). Expected (README line 74): revert, nothing burned. Actual: returns (41, 26456405297011878279983700180394513608970340370262199527078277608063898672485), BMC.balanceOf(alice) == 0, BMC.totalSupply() dropped by 350_000e18, revealStatusOf(41) == AwaitingRandomness, coordinator.pendingRequestExists(subId) == true, and cancelSubscription(subId, subOwner) reverts. Local proof (no fork): forge test --match-path test/scratch/KeyHashNotValidated.t.sol -> FAIL 'setVRFConfig accepted a key hash the coordinator does not serve' after showing the 350,000 BMC burn; passes once setVRFConfig checks s_provingKeys(keyHash).exists on the coordinator.","severity":"medium","snippet":"            subscriptionId == 0 || keyHash == bytes32(0) || callbackGasLimit < MIN_CALLBACK_GAS_LIMIT","title":"setVRFConfig accepts any non-zero key hash; the mainnet coordinator accepts and never fulfils requests for unregistered lanes, so a wrong lane burns 350,000 BMC per mint instead of failing closed"},{"citation":"resolved","description":"The buyer's irreversible payment is taken before the only external dependency is checked for solvency. mintMystery checks cfg.configured and then relies on VRF_COORDINATOR.requestRandomWords reverting to roll back the burn (test_MysteryBurnRevertsIfVRFRequestFails, test_NotAConsumerReverts). But Chainlink VRF v2.5 accepts a request from a registered consumer whose subscription holds zero LINK and zero native balance; payment is only attempted at fulfilment. Chainlink's official VRFCoordinatorV2_5Mock, vendored in lib/ and used by the repository's own test_UnfundedSubscriptionKeepsTokenPendingUntilFunded, behaves the same and that test shows the mint succeeding on an empty subscription. Chainlink's documentation states such requests expire after 24 hours ('If your balance is below the minimum, your requests remain pending for up to 24 hours before they expire'), and the contract has no re-request path, so a buyer who mints while the subscription is empty, a state the buyer cannot see from the mint UI and that is controlled by the subscription owner (a privileged role distinct from the contract owner), permanently loses 350,000 BMC for a token that can never be revealed. Because reveals are strictly ordered that token also freezes every later reveal (high finding). The README text 'Chainlink VRF v2.5 keeps a request pending while the subscription is unfunded and fulfils it after funding' is contradicted by Chainlink's 24h expiry.\n\nFix: before _burnExact, read `(uint96 balance, uint96 nativeBalance,,,) = VRF_COORDINATOR.getSubscription(cfg.subscriptionId)` (IVRFCoordinatorV2Plus already inherits IVRFSubscriptionV2Plus, so no interface change) and revert with a new SubscriptionUnfunded error when the balance matching cfg.nativePayment is zero, or below an owner-set minimum bounded to a sane range. This fails closed exactly like the existing VRFNotConfigured path and changes no price, supply or artwork. It is a mitigation, not a guarantee (a balance above zero can still be below Chainlink's per-request minimum), which is why the recovery path from the high finding is still required. The existing test test_UnfundedSubscriptionKeepsTokenPendingUntilFunded must be rewritten with the fix (verified: it fails with 'subscription unfunded' once the check is added). Also surface pendingReveals and the subscription balance on the website so buyers can see the sale is healthy.","line":248,"path":"src/BMCIlluminatiPass.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {VRFCoordinatorV2_5Mock} from \"@chainlink/contracts/src/v0.8/vrf/mocks/VRFCoordinatorV2_5Mock.sol\";\nimport {BMCIlluminatiPass} from \"src/BMCIlluminatiPass.sol\";\n\n/// @dev Minimal 18-decimal token with OpenZeppelin ERC20Burnable-style burnFrom (spend allowance, destroy).\ncontract PocBMC {\n    uint256 public totalSupply;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function decimals() external pure returns (uint8) {\n        return 18;\n    }\n\n    function mint(address to, uint256 amount) external {\n        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, \"insufficient allowance\");\n        allowance[account][msg.sender] = a - amount;\n        require(balanceOf[account] >= amount, \"insufficient balance\");\n        balanceOf[account] -= amount;\n        totalSupply -= amount;\n    }\n}\n\n/// @notice mintMystery burns 350,000 BMC and opens a VRF request even when the configured subscription\n///         holds no LINK and no native balance, so the request cannot be paid for. Chainlink's v2.5\n///         coordinator accepts such requests (its official mock, used here, behaves the same) and, per\n///         Chainlink's documentation, expires them after 24 hours if the subscription stays underfunded.\n///         The contract has no re-request path, so the buyer's BMC is gone for a permanently pending token.\n///         Expected: the mint fails closed (no burn), as it already does when the request itself reverts.\n///         Actual: the burn succeeds and the pending token is minted.\n///         This test fails on the current code and passes once mintMystery checks the subscription\n///         balance that matches nativePayment (via VRF_COORDINATOR.getSubscription) before burning.\ncontract UnfundedSubscriptionBurnTest is Test {\n    PocBMC bmc;\n    VRFCoordinatorV2_5Mock coordinator;\n    BMCIlluminatiPass pass;\n    uint256 subId;\n\n    address owner = makeAddr(\"owner\");\n    address subOwner = makeAddr(\"subscriptionOwner\");\n    address alice = makeAddr(\"alice\");\n\n    function setUp() public {\n        bmc = new PocBMC();\n        coordinator = new VRFCoordinatorV2_5Mock(0.1 ether, 1e9, 4e15);\n        pass = new BMCIlluminatiPass(owner, address(bmc), address(coordinator));\n\n        vm.startPrank(subOwner);\n        subId = coordinator.createSubscription();\n        coordinator.addConsumer(subId, address(pass));\n        vm.stopPrank();\n        // Subscription deliberately left with zero LINK and zero native balance.\n\n        vm.startPrank(owner);\n        pass.setGenesisImagesCID(\"bafytest\");\n        pass.setVRFConfig(subId, keccak256(\"lane\"), 500_000, 3, false);\n        vm.stopPrank();\n\n        for (uint256 i = 0; i < 40; i++) {\n            address who = address(uint160(0x7000 + i));\n            bmc.mint(who, 1_000_000e18);\n            vm.startPrank(who);\n            bmc.approve(address(pass), 1_000_000e18);\n            pass.mintGenesis();\n            vm.stopPrank();\n        }\n    }\n\n    function test_MysteryMintFailsClosedWhenSubscriptionCannotPayForFulfilment() public {\n        (uint96 balance, uint96 nativeBalance,,,) = coordinator.getSubscription(subId);\n        assertEq(balance, 0);\n        assertEq(nativeBalance, 0);\n\n        bmc.mint(alice, 350_000e18);\n        uint256 supplyBefore = bmc.totalSupply();\n        vm.startPrank(alice);\n        bmc.approve(address(pass), 350_000e18);\n        (bool ok,) = address(pass).call(abi.encodeCall(pass.mintMystery, ()));\n        vm.stopPrank();\n\n        assertFalse(ok, \"mintMystery accepted a burn although the VRF subscription has no balance\");\n        assertEq(bmc.balanceOf(alice), 350_000e18, \"buyer's BMC must be untouched\");\n        assertEq(bmc.totalSupply(), supplyBefore, \"nothing may be burned\");\n        assertEq(pass.totalMinted(), 40, \"no pending token may be minted\");\n    }\n}","reproduction":"State: Chainlink VRFCoordinatorV2_5Mock (lib/chainlink), subscription created by subOwner, pass added as consumer, balance 0 LINK / 0 native. Owner setVRFConfig(subId, lane, 500000, 3, false). Phase 1 complete. alice holds 350_000e18 BMC and approves the pass. alice calls mintMystery(). Expected: revert, alice's balance 350_000e18, totalMinted 40, nothing burned. Actual: call succeeds, 350_000e18 BMC destroyed (totalSupply drops), token 41 minted AwaitingRandomness, request accepted; coordinator.fulfillRandomWords then reverts InsufficientBalance (shown by the repository's own test) and per Chainlink's documentation the request expires after 24h with no way to re-request. Run: forge test --match-path test/scratch/UnfundedSubscriptionBurn.t.sol -> FAIL 'mintMystery accepted a burn although the VRF subscription has no balance'; passes once mintMystery checks the matching subscription balance before burning (verified on a patched copy).","severity":"medium","snippet":"        _burnExact(msg.sender, MYSTERY_PRICE);","title":"mintMystery burns 350,000 BMC and opens a VRF request even when the subscription has no balance to pay for fulfilment"},{"citation":"resolved","description":"The contract inherits OpenZeppelin's single-step Ownable, not Ownable2Step; transferOwnership(address) takes effect immediately and renounceOwnership() is available at any time. Both owner powers are prerequisites for minting: setGenesisImagesCID gates mintGenesis and mintMystery, setVRFConfig gates mintMystery, and setVRFConfig is load-bearing for the whole phase 2 because the subscription id and key hash depend on Chainlink-side state the contract does not control (three lanes exist on mainnet today; Chainlink has retired lanes before, and a subscription can be cancelled by its owner). If the owner key is lost (keystroke error in transferOwnership, compromised key renouncing, or the early renounce that README O-5 contemplates) before the CID is set, no token can ever be minted and the deployment is dead; if it happens before a working VRF config, or the configured lane or subscription later becomes invalid, requestRandomWords reverts inside every mintMystery (InvalidKeyHash / InvalidSubscription / InvalidConsumer) and phase 2 stops permanently short of 500 tokens with the remaining Genesis and Standard outcomes never minted. The README (line 76) only notes that renouncing 'freezes the VRF config forever' and does not spell out that the collection can then never complete. This is a documented trust assumption, reported because the brief asks for review of transferOwnership and renounceOwnership.\n\nFix preserving the design: inherit Ownable2Step (the pending owner must call acceptOwnership), and override renounceOwnership to revert unless genesisCIDLocked && vrfConfig.configured (or until totalMinted == MAX_SUPPLY), or disable it entirely. Using a multisig as owner (README O-5) is complementary.","line":40,"path":"src/BMCIlluminatiPass.sol","reproduction":"1) Deploy BMCIlluminatiPass(owner, BMC, coordinator). 2) owner calls renounceOwnership() (or transferOwnership(0x000...dead), accepted in one step). 3) owner calls setGenesisImagesCID('bafy...') -> reverts OwnableUnauthorizedAccount. 4) mintGenesis() -> reverts GenesisCIDNotSet; mintMystery() -> reverts GenesisCIDNotSet. Expected: the configuration steps cannot be irrecoverably skipped by one call. Actual: the contract is permanently unusable and must be redeployed. Verified with test/scratch/Misc.t.sol::test_RenounceBeforeCID (passes on the current code, i.e. the lockout is real). Second input: totalMinted == 200, owner transfers to a mistyped address, Chainlink later retires the configured lane: every mintMystery reverts in requestRandomWords, setVRFConfig reverts OwnableUnauthorizedAccount, 300 tokens including all 37 mystery Genesis can never be minted.","severity":"low","snippet":"contract BMCIlluminatiPass is ERC721, Ownable, ReentrancyGuard {","title":"Single-step Ownable: renounceOwnership or a mistyped transferOwnership before configuration permanently disables minting, and after it freezes a possibly-broken VRF config"},{"citation":"resolved","description":"README line 132 gives `forge build && forge test && forge fmt --check` as the test command, README line 19 and docs/TEST-RESULTS.md record 43 passing tests. The fork test hard-requires chain id 1 instead of skipping when no fork is configured, so plain `forge test` now reports 1 failing test out of 44, and the file is not formatted (leading blank line and a space before the SPDX header, wrapped constant declarations), so `forge fmt --check` exits 1. CI only passes because .github/workflows/main.yml excludes the contract with --no-match-contract and re-runs it with a secret RPC. A reviewer, or the contributor fixing any of the findings above, who follows the README sees a red suite unrelated to their change. The test itself is valuable: against a live mainnet fork it passes (verified in this review at block ~26174082: real BMC burnFrom destroys exactly 1,000,000e18 and totalSupply drops accordingly).\n\nFix: start the test with `string memory url = vm.envOr(\"MAINNET_RPC_URL\", string(\"\")); if (bytes(url).length == 0) { vm.skip(true); return; } vm.createSelectFork(url);` (or `if (block.chainid != 1) { vm.skip(true); return; }`), run `forge fmt`, and update README line 19 and docs/TEST-RESULTS.md to 44 tests with the fork test listed separately.","line":27,"path":"test/BMCMainnetFork.t.sol","reproduction":"Fresh clone, no RPC: `forge test` -> 'Ran 3 test suites ... 43 tests passed, 1 failed, 0 skipped (44 total tests)' with '[FAIL: Ethereum mainnet fork required] test_RealBMCGenesisMint() (gas: 372)'. `forge fmt --check test/BMCMainnetFork.t.sol` -> exit code 1. `grep -c 'function test' test/*.sol` -> 39 + 1 + 4 = 44. Expected per README/TEST-RESULTS: all tests pass, formatting clean, 43 tests. With `--fork-url <mainnet RPC>` the test passes (verified).","severity":"low","snippet":"        require(block.chainid == 1, \"Ethereum mainnet fork required\");","title":"Documented verification commands fail: plain `forge test` fails on the fork test, `forge fmt --check` fails, and the recorded test count (43) is stale"},{"citation":"resolved","description":"Audit scope item 4 asks to confirm the generator cannot accidentally overwrite the approved premium Genesis files. It can: with no argument `out` resolves to artwork/genesis (line 196), os.makedirs(out, exist_ok=True) on line 197 accepts the existing directory, and img.save(...) on line 201 overwrites each of the 77 PNGs without an existence check, a hash comparison against the committed bytes, or a confirmation prompt. README line 133 even instructs running it against that directory. The 'byte-identical' claim depends on the installed Pillow release: the plate label is drawn with Pillow's default bitmap font (d.text with no font argument, line 191), GaussianBlur and the optimize=True PNG encoder have all changed output across Pillow versions, so a re-run on another machine can silently replace the approved artwork with visually similar but byte-different files. If the IPFS upload happens afterwards the directory CID changes, and setGenesisImagesCID locks whatever CID is uploaded forever. Determinism could not be verified in this environment (Pillow is not installed), so that part is reported as unverified; the unconditional overwrite is verified from the source.\n\nFix: default to a separate output directory (e.g. artwork/generated), refuse to write when a target file exists unless --force is passed, record the SHA-256 of the 77 approved files in the repository (e.g. artwork/genesis/SHA256SUMS; genesis-01.png today hashes to b35a60c9a4fb8227ca6dd4874dca5e9d3ef7948ac0ccdb7b770851e0ae11c406) and check the upload against them before setGenesisImagesCID, and pin the Pillow version in a requirements file.","line":196,"path":"artwork/tools/generate_genesis.py","reproduction":"State: artwork/genesis contains the 77 approved PNGs. Input: `python3 artwork/tools/generate_genesis.py` (no argument) with any Pillow version installed. Expected: the approved files are left untouched or the tool refuses. Actual: out == artwork/genesis, makedirs succeeds because exist_ok=True, and all 77 files are rewritten unconditionally by img.save on line 201; whether the bytes match the approved files depends on the installed Pillow release.","severity":"low","snippet":"    out = sys.argv[1] if len(sys.argv) > 1 else os.path.join(os.path.dirname(__file__), \"..\", \"genesis\")","title":"generate_genesis.py writes into the approved artwork/genesis directory by default and overwrites genesis-01.png .. genesis-77.png with no hash check; byte-identical regeneration depends on the Pillow "},{"citation":"resolved","description":"Statements the operator will rely on do not match measured or documented behaviour. (1) README line 105: with callbackGasLimit 500,000 the callback has about 467k gas left after storing the word, batch = (467k - 30k) / 110k = 3, so at most 3 reveals run inline; 4 requires >= ~504k. (2) src line 94 says the per-reveal cost was 'measured ~85k'; a first permissionless finalizeReveals(1) measured in this review consumes 133,705 gas of execution (the ~85k figure appears to include end-of-transaction SSTORE refunds, which do not reduce gasleft()). At the minimum permitted MIN_CALLBACK_GAS_LIMIT = 150,000 the callback stores the word and finalizes nothing (batch == 0), so every reveal then depends on an unpaid finalizeReveals call. Nothing is lost on failure because the try/catch keeps the word, so this is informational, but the batch sizing margin in the 175k-250k band is under 10% against the 63/64 forwarding rule. (3) README lines 64-67 and NatSpec lines 38-39: Chainlink documents that requests on an underfunded subscription 'remain pending for up to 24 hours before they expire'; they are not fulfilled whenever funding arrives (this is the root of the high finding). (4) README line 19 and docs/TEST-RESULTS.md: the repository has 44 tests. Fix: recommend 550,000 callback gas (or re-measure and set GAS_PER_REVEAL to ~140k and raise MIN_CALLBACK_GAS_LIMIT so a callback can always store and finalize at least one token), correct the expiry wording, and refresh the counts.","line":105,"path":"README.md","reproduction":"Project MockVRFCoordinator (forwards exactly callbackGasLimit), phase 1 complete, setVRFConfig(1, lane, 500000, 3, false): mint tokens 41..46; deliver 46,45,44,43,42 with 95,000 gas (stored only, revealedCount == 0); deliver 41 with the request's own 500,000 gas. Expected per README: revealedCount() == 4. Actual: revealedCount() == 3 (test/scratch/Misc.t.sol::test_BatchAt500k prints 'revealed by 500k callback 3'). With callbackGasLimit 150,000: deliver 41 -> revealedCount() == 0; a fresh finalizeReveals(1) then costs 133,705 gas (test_BatchAt150k). `grep -c 'function test' test/*.sol` -> 44.","severity":"info","snippet":"   500k callback gas lets each callback finalize up to 4 reveals. `nativePayment=true` if funded with ETH.","title":"README and NatSpec operational claims do not match measured behaviour: a 500k callback finalizes 3 reveals not 4, a 150k callback finalizes none, GAS_PER_REVEAL comment understates the cost, unfunded "},{"citation":"resolved","description":"run() always uses MAINNET_BMC and MAINNET_VRF_COORDINATOR and reads only OWNER from the environment; neither run() nor deploy() checks block.chainid or that cfg.bmc / cfg.vrfCoordinator have code. Run against any other RPC (a testnet rehearsal or a wrong --rpc-url), it deploys a contract pointing at addresses with no code there: setGenesisImagesCID succeeds, then every mintGenesis reverts inside BMC.decimals() (staticcall to an address without code returns no data and the ABI decoder reverts), so the deployment is useless while the deployer may believe the rehearsal represents mainnet. Fix: `require(block.chainid == 1, \"mainnet only\");` in run(), and in deploy() `require(cfg.bmc.code.length > 0 && cfg.vrfCoordinator.code.length > 0, \"no code at dependency\");`.","line":35,"path":"script/Deploy.s.sol","reproduction":"OWNER=<addr> forge script script/Deploy.s.sol:DeployBMCIlluminatiPass --rpc-url <any non-mainnet RPC> --broadcast: succeeds. On the resulting contract, after setGenesisImagesCID, mintGenesis() reverts with an empty revert from the decimals() staticcall to 0x38224239f2BDae357D47cb864BC533d7f72dF961 (no code on that chain). Expected: the script refuses to run off mainnet or against code-less dependencies.","severity":"info","snippet":"        pass = deploy(mainnetConfig(owner));","title":"Deploy script hard-codes mainnet addresses but never checks block.chainid == 1 or that the targets have code"},{"citation":"resolved","description":"_burnExact checks two deltas but the error always carries supplyDelta. When the token destroys exactly `amount` of supply but not from `payer` (a burnFrom that burns from a treasury and only debits the payer a fee), supplyDelta == amount and balanceDelta != amount, so the revert data reads BurnMismatch(1000000e18, 1000000e18), which claims the burn matched. Separately, lines 278-279 compute supplyBefore - totalSupply() and balanceBefore - balanceOf(payer) with checked arithmetic; if either value grows during burnFrom (a token that mints a rebate or hooks a transfer back to the payer) the call reverts with Panic(0x11) rather than BurnMismatch. Both paths fail closed so there is no loss; the diagnostics are misleading, which matters because this error is the contract's sole on-chain evidence about an unexpected BMC behaviour. Fix: compare before/after values without subtraction that can underflow and report both deltas, e.g. `revert BurnMismatch(amount, supplyDelta, balanceDelta)`, or two distinct errors.","line":280,"path":"src/BMCIlluminatiPass.sol","reproduction":"Deploy with a mock whose burnFrom(account, amount) does _spendAllowance(account, msg.sender, amount), _burn(treasury, amount) and _transfer(account, treasury, amount / 100). Fund payer with 1_000_000e18, approve, call mintGenesis(). Expected: a revert identifying the payer-balance mismatch (balance fell by 10_000e18 instead of 1_000_000e18). Actual: revert BurnMismatch(1000000000000000000000000, 1000000000000000000000000). Second input: a mock whose burnFrom mints amount*2 to account before burning amount; expected BurnMismatch, actual Panic(0x11) from line 279. Both verified by test/scratch/Misc.t.sol::test_BurnMismatchDiag (expectRevert with exactly those payloads passes on the current code).","severity":"info","snippet":"        if (supplyDelta != amount || balanceDelta != amount) revert BurnMismatch(amount, supplyDelta);","title":"BurnMismatch reports the supply delta even when the payer-balance delta is the mismatching operand, and a supply or balance increase during burnFrom panics instead of reverting with BurnMismatch"},{"citation":"resolved","description":"The coordinator is immutable by design (docs/SECURITY-REVIEW.md F-3), which removes the owner's reroll-by-proxy power and is the right call. Two consequences should be stated as accepted risks. (1) The live VRFCoordinatorV2_5.migrate(subId, newCoordinator) (verified source read from Sourcify during this review) loops over the subscription's consumers and calls `IVRFMigratableConsumerV2Plus(consumers[i]).setCoordinator(newCoordinator)`; the pass has no such function and no fallback, so the subscription owner cannot migrate a subscription that lists the pass as a consumer (the call reverts) and must removeConsumer first, which itself requires no pending request. (2) If Chainlink retires the v2.5 coordinator or all its lanes during the sale, mintMystery either reverts (no consumer / no subscription) or keeps burning BMC for requests nobody fulfils (key-hash finding), and any request outstanding at that moment freezes all later reveals (high finding). The README mentions decommissioning only for 'affected tokens'. Recommendation: keep the immutable coordinator, document these outcomes, keep the sale window short relative to Chainlink's deprecation notice periods, and rely on the recovery path from the high finding.","line":99,"path":"src/BMCIlluminatiPass.sol","reproduction":"On the verified mainnet coordinator source, migrate() contains `for (uint256 i = 0; i < consumers.length; ++i) { IVRFMigratableConsumerV2Plus(consumers[i]).setCoordinator(newCoordinator); }`. With the pass registered as a consumer, the external call hits a contract with neither a setCoordinator(address) selector nor a fallback, so migrate reverts. Expected by a subscription owner: migration proceeds; actual: revert until the pass is removed as consumer.","severity":"info","snippet":"    IVRFCoordinatorV2Plus public immutable VRF_COORDINATOR;","title":"Immutable coordinator with no setCoordinator: subscription migrate() reverts for this consumer and a coordinator deprecation strands all future mystery mints (accepted trust dependency, undocumented c"},{"citation":"resolved","description":"Two information effects of the design, not permission bypasses, that affect who pays 350,000 BMC for what. (a) `remaining` depends only on revealedCount, so for a token whose word is stored but which is blocked behind an earlier AwaitingRandomness token, r is already fixed and _slotValue(r) is the outcome unless an earlier blocked draw lands on the same slot. The word is also emitted in RandomnessStored. Anyone reading the chain knows the near-certain tier of a 'Pending' token; a marketplace buyer who does not will overpay for a known-Standard token or be front-run on a known-Genesis one. (b) The inventory is public (remainingMysteryGenesis / remainingMysteryStandard). Once all 37 mystery Genesis are drawn, every remaining mystery mint is a guaranteed Standard at the same 350,000 BMC; conversely with one Genesis slot left and one mint left, the final mint is a guaranteed Genesis for 350,000 BMC versus the 1,000,000 BMC Genesis price, and the last mints become a gas auction. The website already shows provisional odds, so this is informational: document both effects for buyers, consider not emitting the raw word, and have the website refuse to show 'odds' once they are 0% or 100%.","line":334,"path":"src/BMCIlluminatiPass.sol","reproduction":"(a) Phase 1 done; mint 41 (request never fulfilled) and 42; deliver 42's word w = keccak256('w42'). Compute r = uint256(keccak256(abi.encode(w, 42))) % 460 = 235 off-chain (test/scratch/Misc.t.sol::test_KnowableOutcome); r >= 37 so token 42 is Standard artwork 199 as soon as 41 is finalized, while tokenURI(42) still says 'Pending Reveal' and tierOf(42) == Pending. (b) State revealedCount == 459, mysteryGenesisRevealed == 36, totalMinted == 499: currentPrice() == 350_000e18 and the next mintMystery() is certain to draw the last Genesis (remaining == 1, the only slot left is < 37).","severity":"info","snippet":"        uint256 r = uint256(keccak256(abi.encode(word, tokenId))) % remaining;","title":"Public without-replacement pool makes blocked and late mystery outcomes knowable before finalization"}],"hash":"bba3408249ecfbcc380e9ec156b5fd47764d26de344da792a3935f385a40bb39","nodeId":"aaf15685-3e1b-43c9-b07f-0c5b6de523b4","outcome":"completed","summary":"The review is complete. The findings file holds 11 verified findings, three with proofs, and the working tree is unchanged apart from the scratch directory.\n\n**What I did**\n\nI reproduced every specialist finding against the code rather than trusting the reports. All four attached proofs failed on the current code for their stated reasons. I confirmed the two external claims the high finding rests on: Chainlink's VRF v2.5 docs state that requests on an underfunded subscription expire after 24 hours, and the verified mainnet coordinator source skips key-hash validation and calls setCoordinator on consumers during migrate. On a mainnet fork at block 26174090 I showed the real coordinator accepting a mint with an unregistered key hash, burning 350,000 BMC and leaving a request that can never be fulfilled or cancelled. For each proof I kept, I also applied the proposed fix to a temporary copy and confirmed the test passes, then restored the file.\n\n**Findings kept, after merging duplicates**\n\n- **High:** one never-fulfilled VRF request freezes every later reveal forever. Five specialist reports merged into one. My own fix-agnostic proof passes with a re-request, a skip, or delivery-order finalization.\n- **Medium:** setVRFConfig accepts unregistered key hashes, and the README's fail-closed claim is false. Three reports merged, with a local proof plus the fork reproduction.\n- **Medium:** mintMystery burns BMC while the subscription has zero balance. Proof rewritten to be self-contained; the repo's own test that encodes the wrong assumption fails once the fix is in, which the finding states.\n- **Low:** single-step Ownable lockout, fork test breaking plain `forge test` and `forge fmt --check`, and the generator overwriting approved Genesis PNGs. Each merged from two reports.\n- **Info:** README and gas-constant inaccuracies (three reports merged), deploy script with no chain-id guard, BurnMismatch diagnostics, immutable coordinator consequences, and knowable outcomes.\n\n**Dropped or adjusted**\n\nNothing was dropped outright; every report reproduced. The audit_math \"medium\" that combined head-of-line blocking with the key-hash issue was split back into those two findings. I downgraded nothing and upgraded nothing beyond the specialists' consensus.\n\n**Unverified**\n\nByte-identical regeneration of the Genesis PNGs could not be checked because Pillow is not installed, and the finding says so. Slither was not run.","treeHash":null,"usage":{"cachedInputTokens":1976254,"inputTokens":610,"model":"claude-fable-5-1","outputTokens":45865,"runtime":"claude","turns":41,"wallClockMs":626869}}],"verification":[]}