// SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {D20VRFConsumer} from "@d20dao/vrf-sdk/contracts/D20VRFConsumer.sol"; import {ID20VRF} from "@d20dao/vrf-sdk/contracts/interfaces/ID20VRF.sol"; import {RandomnessMapping} from "@d20dao/vrf-sdk/contracts/libraries/RandomnessMapping.sol"; /// @notice Educational assignment module. The collection must enforce mint closure and its ownership rules. contract OneShotNFTReveal is D20VRFConsumer { enum State { Ready, Pending, Revealed, Expired } uint32 public constant CALLBACK_GAS = 100_000; address public immutable collection; bytes32 public immutable commitment; uint256[] public tokenIds; bytes32[] public metadataHashes; State public state; uint256 public requestId; bytes32 public word; error InvalidCollection(); error InvalidAssignment(); error NotCollection(); error InvalidRefundAddress(); error AlreadyStarted(); error UnexpectedCallback(); error NotRevealed(); error InvalidPosition(); event RevealRequested(uint256 indexed requestId, bytes32 commitment); event RevealCompleted(uint256 indexed requestId, bytes32 word); event RevealExpired(uint256 indexed requestId); constructor(address coordinator, address fixedCollection, uint256[] memory fixedTokenIds, bytes32[] memory fixedMetadataHashes) D20VRFConsumer(coordinator) { if (fixedCollection.code.length == 0) revert InvalidCollection(); uint256 n = fixedTokenIds.length; if (n == 0 || n > 64 || fixedMetadataHashes.length != n) revert InvalidAssignment(); for (uint256 i; i < n; ++i) { if (fixedMetadataHashes[i] == bytes32(0)) revert InvalidAssignment(); for (uint256 j; j < i; ++j) { if (fixedTokenIds[i] == fixedTokenIds[j]) revert InvalidAssignment(); } } collection = fixedCollection; tokenIds = fixedTokenIds; metadataHashes = fixedMetadataHashes; commitment = keccak256(abi.encode(fixedCollection, fixedTokenIds, fixedMetadataHashes)); } function tokenCount() external view returns (uint256) { return tokenIds.length; } /// @notice The collection closes entry changes first, then forwards the original sponsor's refund address. function requestReveal(address refundAddress) external payable { if (msg.sender != collection) revert NotCollection(); if (state != State.Ready) revert AlreadyStarted(); if (refundAddress == address(0) || refundAddress == address(this)) revert InvalidRefundAddress(); state = State.Pending; requestId = ID20VRF(vrfCoordinator).requestMappedRandomness{value: msg.value}( commitment, CALLBACK_GAS, refundAddress, _shuffle() ); emit RevealRequested(requestId, commitment); } function _fulfillRandomness(uint256 id, bytes32 randomness) internal override { if (requestId == 0 || id != requestId) revert UnexpectedCallback(); if (state == State.Revealed && randomness == word) return; if (state != State.Pending) revert UnexpectedCallback(); word = randomness; state = State.Revealed; emit RevealCompleted(id, randomness); } function _onRefund(uint256 id) internal override { if (requestId == 0 || id != requestId) revert UnexpectedCallback(); if (state == State.Expired) return; if (state != State.Pending) revert UnexpectedCallback(); state = State.Expired; emit RevealExpired(id); } function permutation() public view returns (uint256[] memory) { if (state != State.Revealed) revert NotRevealed(); return RandomnessMapping.map(word, _shuffle()); } /// @notice The argument is a position in tokenIds, not the ERC-721 token ID. function metadataIndex(uint256 tokenPosition) public view returns (uint256) { if (tokenPosition >= tokenIds.length) revert InvalidPosition(); return permutation()[tokenPosition]; } function assignedMetadataHash(uint256 tokenPosition) external view returns (bytes32) { return metadataHashes[metadataIndex(tokenPosition)]; } function _shuffle() private view returns (RandomnessMapping.Spec memory) { uint32 n = uint32(tokenIds.length); return RandomnessMapping.Spec(RandomnessMapping.Operation.Shuffle, 0, 0, n, n); } }