// SPDX-License-Identifier: MIT pragma solidity 0.8.24; import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {Base64} from "@openzeppelin/contracts/utils/Base64.sol"; import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; import {IVRFCoordinatorV2Plus} from "@chainlink/contracts/src/v0.8/vrf/dev/interfaces/IVRFCoordinatorV2Plus.sol"; import {VRFV2PlusClient} from "@chainlink/contracts/src/v0.8/vrf/dev/libraries/VRFV2PlusClient.sol"; /// @notice A fixed 4,096-edition NFT collection gated by the configured Agent ID NFT. /// @dev The official Chainlink request interface/client are used with a CUSTOM, /// immutable-coordinator callback adapter. The migratable ConsumerBase is not /// inherited: its non-virtual coordinator setter would violate this collection's /// no-reroll trust boundary. This adapter has not received an independent audit. /// The constructor commits a manifest hash and URI prefix; it does not establish /// the external manifest's contents, availability, correctness, or provenance. contract BotblockGenesis is ERC721, ReentrancyGuard { using Strings for uint256; struct DeploymentConfig { address agentIdRegistry; address payable treasury; uint256 mintPrice; uint64 opensAt; uint64 closesAt; bytes32 manifestHash; string metadataBaseURI; } struct VrfConfig { address coordinator; uint256 subscriptionId; bytes32 keyHash; uint16 requestConfirmations; uint32 callbackGasLimit; bool nativePayment; } uint256 public constant MAX_SUPPLY = 4096; uint256 public constant FEISTEL_ROUNDS = 8; bytes32 public constant BOTBLOCK_GENERATION = keccak256("BOTBLOCK_GENESIS_METADATA_V1"); IERC721 public immutable agentIdRegistry; address payable public immutable treasury; uint256 public immutable mintPrice; uint64 public immutable opensAt; uint64 public immutable closesAt; bytes32 public immutable manifestHash; // Solidity does not support immutable strings; this value is set once in // the constructor and there is no function that can subsequently change it. string public metadataBaseURI; IVRFCoordinatorV2Plus public immutable vrfCoordinator; uint256 public immutable subscriptionId; bytes32 public immutable keyHash; uint16 public immutable requestConfirmations; uint32 public immutable callbackGasLimit; bool public immutable nativePayment; uint256 public totalSupply; mapping(address => bool) public hasMinted; mapping(uint256 => address) public originalMinter; bool public revealRequested; bool public revealed; uint256 public requestId; uint256 public revealSeed; // Becomes true only AFTER the request call returns and requestId is stored. // A synchronous coordinator callback, including one with ID zero, is ignored. bool private callbackArmed; error InvalidRegistry(); error InvalidTreasury(); error InvalidTiming(); error InvalidManifest(); error InvalidBaseURI(); error InvalidCoordinator(); error InvalidVrfConfiguration(); error MintClosed(); error SoldOut(); error AgentIdRequired(); error AlreadyMinted(); error IncorrectPayment(uint256 expected, uint256 received); error SaleNotClosed(); error EmptyCollection(); error RevealAlreadyRequested(); error NotRevealed(); error InvalidTokenId(); error OnlyCoordinatorCanFulfill(address caller, address expected); error NothingToWithdraw(); error WithdrawalFailed(); event GenesisMinted(address indexed minter, uint256 indexed tokenId); event RevealRequested(uint256 indexed requestId, uint256 mintedSupply); event Revealed(uint256 indexed requestId, uint256 seed); event BatchMetadataUpdate(uint256 _fromTokenId, uint256 _toTokenId); event RevenueWithdrawn(address indexed treasury, uint256 amount); constructor(DeploymentConfig memory config, VrfConfig memory vrf) ERC721("BOTBLOCK Genesis", "BLOCK") { if (config.agentIdRegistry == address(0) || config.agentIdRegistry.code.length == 0) { revert InvalidRegistry(); } try IERC721(config.agentIdRegistry).balanceOf(address(this)) returns (uint256) {} catch { revert InvalidRegistry(); } if (config.treasury == address(0) || config.treasury == address(this)) revert InvalidTreasury(); if (config.opensAt >= config.closesAt || config.closesAt <= block.timestamp) revert InvalidTiming(); if (config.manifestHash == bytes32(0)) revert InvalidManifest(); bytes memory uri = bytes(config.metadataBaseURI); if (uri.length == 0 || uri[uri.length - 1] != bytes1("/")) revert InvalidBaseURI(); if (vrf.coordinator == address(0) || vrf.coordinator.code.length == 0) revert InvalidCoordinator(); // Bounds cover this minimal storage-only callback; the selected live // coordinator/key must still be checked for its own network constraints. if ( vrf.subscriptionId == 0 || vrf.keyHash == bytes32(0) || vrf.requestConfirmations < 3 || vrf.requestConfirmations > 200 || vrf.callbackGasLimit < 100_000 || vrf.callbackGasLimit > 2_500_000 ) revert InvalidVrfConfiguration(); agentIdRegistry = IERC721(config.agentIdRegistry); treasury = config.treasury; mintPrice = config.mintPrice; opensAt = config.opensAt; closesAt = config.closesAt; manifestHash = config.manifestHash; metadataBaseURI = config.metadataBaseURI; vrfCoordinator = IVRFCoordinatorV2Plus(vrf.coordinator); subscriptionId = vrf.subscriptionId; keyHash = vrf.keyHash; requestConfirmations = vrf.requestConfirmations; callbackGasLimit = vrf.callbackGasLimit; nativePayment = vrf.nativePayment; } /// @notice Closure is automatic and irreversible: either deadline or sellout. function saleClosed() public view returns (bool) { return block.timestamp >= closesAt || totalSupply >= MAX_SUPPLY; } function saleOpen() public view returns (bool) { return block.timestamp >= opensAt && !saleClosed(); } function canMint(address wallet) external view returns (bool) { if (wallet == address(0) || !saleOpen() || hasMinted[wallet]) return false; try agentIdRegistry.balanceOf(wallet) returns (uint256 amount) { return amount > 0; } catch { return false; } } /// @notice One original mint per eligible wallet. Native BNB mint price only; /// gas is additional. No class, rarity, edition or recipient can be selected. function mint() external payable nonReentrant returns (uint256 tokenId) { if (totalSupply >= MAX_SUPPLY) revert SoldOut(); if (!saleOpen()) revert MintClosed(); if (hasMinted[msg.sender]) revert AlreadyMinted(); if (agentIdRegistry.balanceOf(msg.sender) == 0) revert AgentIdRequired(); if (msg.value != mintPrice) revert IncorrectPayment(mintPrice, msg.value); tokenId = ++totalSupply; hasMinted[msg.sender] = true; originalMinter[tokenId] = msg.sender; _safeMint(msg.sender, tokenId); emit GenesisMinted(msg.sender, tokenId); } /// @notice Anybody may request the only reveal after mint inputs are frozen. /// @dev If the coordinator reverts, the entire call rolls back and no request /// was accepted. Once it returns, no retry/cancel/replacement function exists. /// Subscription funding is external to this collection's native mint revenue. function requestReveal() external nonReentrant returns (uint256 acceptedRequestId) { if (!saleClosed()) revert SaleNotClosed(); if (totalSupply == 0) revert EmptyCollection(); if (revealRequested) revert RevealAlreadyRequested(); revealRequested = true; acceptedRequestId = vrfCoordinator.requestRandomWords( VRFV2PlusClient.RandomWordsRequest({ keyHash: keyHash, subId: subscriptionId, requestConfirmations: requestConfirmations, callbackGasLimit: callbackGasLimit, numWords: 1, extraArgs: VRFV2PlusClient._argsToBytes( VRFV2PlusClient.ExtraArgsV1({nativePayment: nativePayment}) ) }) ); requestId = acceptedRequestId; callbackArmed = true; emit RevealRequested(acceptedRequestId, totalSupply); } /// @notice CUSTOM immutable-coordinator adapter for Chainlink VRF v2.5. /// @dev Intentionally no owner/migration/setCoordinator function. The trusted /// address must be verified before deployment. Live oracle compatibility is /// not proved by tests against a local mock coordinator. function rawFulfillRandomWords(uint256 fulfilledRequestId, uint256[] calldata randomWords) external { if (msg.sender != address(vrfCoordinator)) { revert OnlyCoordinatorCanFulfill(msg.sender, address(vrfCoordinator)); } // Ignore unmatched, premature, duplicate or malformed authenticated // callbacks without reverting. Only one seed can ever be recorded. if (!callbackArmed || revealed || fulfilledRequestId != requestId || randomWords.length != 1) return; callbackArmed = false; revealSeed = randomWords[0]; // Zero is a valid VRF output. revealed = true; emit Revealed(fulfilledRequestId, randomWords[0]); emit BatchMetadataUpdate(1, totalSupply); } /// @notice ERC-4906 lets marketplaces refresh the revealed metadata once. function supportsInterface(bytes4 interfaceId) public view override returns (bool) { return interfaceId == bytes4(0x49064906) || super.supportsInterface(interfaceId); } /// @notice Eight-round balanced 12-bit Feistel permutation over 0..4095. /// @dev For every fixed seed it is bijective. Its review and exhaustive local /// tests do not imply a cryptographic audit or perfect uniformity over all /// possible permutations. abi.encode types/order are bytes32 domain, /// uint256 round, uint256 seed, uint256 right. IDs enter/leave as 1..4096. function previewMetadataId(uint256 tokenId, uint256 seed) public pure returns (uint256) { if (tokenId == 0 || tokenId > MAX_SUPPLY) revert InvalidTokenId(); uint256 index = tokenId - 1; uint256 left = index >> 6; uint256 right = index & 63; for (uint256 round = 0; round < FEISTEL_ROUNDS; ++round) { uint256 roundValue = uint256(keccak256(abi.encode(BOTBLOCK_GENERATION, round, seed, right))) & 63; (left, right) = (right, left ^ roundValue); } return ((left << 6) | right) + 1; } function metadataIdOf(uint256 tokenId) public view returns (uint256) { _requireOwned(tokenId); if (!revealed) revert NotRevealed(); return previewMetadataId(tokenId, revealSeed); } function tokenURI(uint256 tokenId) public view override returns (string memory) { _requireOwned(tokenId); if (revealed) { return string.concat(metadataBaseURI, previewMetadataId(tokenId, revealSeed).toString(), ".json"); } // No image, rarity or chosen edition is fabricated before fulfillment. return string.concat( "data:application/json;base64,", Base64.encode(abi.encodePacked( '{"name":"BOTBLOCK Unrevealed #', tokenId.toString(), '","description":"Unrevealed BOTBLOCK Genesis NFT. Metadata assignment is pending the single configured VRF reveal after irreversible mint closure.",', '"attributes":[{"trait_type":"Reveal status","value":"Unrevealed"}]}' )) ); } /// @notice Anyone can release revenue, only to the fixed deployment treasury. function withdraw() external nonReentrant { uint256 amount = address(this).balance; if (amount == 0) revert NothingToWithdraw(); (bool success,) = treasury.call{value: amount}(""); if (!success) revert WithdrawalFailed(); emit RevenueWithdrawn(treasury, amount); } }