// 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 winner selection only: no tickets, prize custody or payout. contract OneShotGiveaway is D20VRFConsumer { enum State { Ready, Pending, Drawn, Expired } uint32 public constant CALLBACK_GAS = 100_000; bytes32 public immutable entrantsHash; address[] public entrants; State public state; uint256 public requestId; bytes32 public word; error InvalidEntrants(); error AlreadyStarted(); error UnexpectedCallback(); error NoWinner(); event DrawRequested(uint256 indexed requestId, bytes32 entrantsHash); event DrawCompleted(uint256 indexed requestId, bytes32 word); event DrawExpired(uint256 indexed requestId); constructor(address coordinator, address[] memory fixedEntrants) D20VRFConsumer(coordinator) { if (fixedEntrants.length == 0 || fixedEntrants.length > 64) revert InvalidEntrants(); for (uint256 i; i < fixedEntrants.length; ++i) { if (fixedEntrants[i] == address(0)) revert InvalidEntrants(); for (uint256 j; j < i; ++j) { if (fixedEntrants[i] == fixedEntrants[j]) revert InvalidEntrants(); } } entrants = fixedEntrants; entrantsHash = keccak256(abi.encode(fixedEntrants)); } function entrantCount() external view returns (uint256) { return entrants.length; } /// @notice Anyone can fund the single draw. That sponsor is the fixed refund recipient. function draw() external payable { if (state != State.Ready) revert AlreadyStarted(); // Effects precede the external request; no caller can start a second draw. state = State.Pending; // The coordinator checks its exact quote and credits excess to this fixed sponsor. requestId = ID20VRF(vrfCoordinator).requestMappedRandomness{value: msg.value}( entrantsHash, CALLBACK_GAS, msg.sender, _choice() ); emit DrawRequested(requestId, entrantsHash); } function _fulfillRandomness(uint256 id, bytes32 randomness) internal override { if (requestId == 0 || id != requestId) revert UnexpectedCallback(); if (state == State.Drawn && randomness == word) return; if (state != State.Pending) revert UnexpectedCallback(); word = randomness; state = State.Drawn; emit DrawCompleted(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(); // Notification follows fee settlement; expiry is terminal, never a reroll. state = State.Expired; emit DrawExpired(id); } function winnerIndex() public view returns (uint256) { if (state != State.Drawn) revert NoWinner(); return RandomnessMapping.map(word, _choice())[0]; } function winner() external view returns (address) { return entrants[winnerIndex()]; } function _choice() private view returns (RandomnessMapping.Spec memory) { return RandomnessMapping.Spec(RandomnessMapping.Operation.ChooseOne, 0, 0, 1, uint32(entrants.length)); } }