// SPDX-License-Identifier: MIT pragma solidity ^0.8.30; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {CheckpointToken} from "./CheckpointToken.sol"; import {IFutureRandomness} from "./randomness/IFutureRandomness.sol"; /// @notice A fee-funded draw with full-epoch holding eligibility and no privileged operator. contract FiveMinuteLottery is ReentrancyGuard { uint64 public constant EPOCH_DURATION = 300; uint256 public constant MAX_BATCH_SIZE = 200; uint256 public constant AUTO_PAYOUT_GAS_LIMIT = 50000; uint256 public constant PAYOUT_GAS_RESERVE = 60000; uint256 public constant MIN_PAYOUT_GAS = AUTO_PAYOUT_GAS_LIMIT + PAYOUT_GAS_RESERVE + 120000; CheckpointToken public immutable token; IFutureRandomness public immutable randomnessAdapter; uint256 public immutable tokensPerTicket; uint64 public immutable epochZero; uint64 public immutable beaconSafetyDelay; uint64 public immutable beaconGenesis; uint64 public immutable beaconPeriod; uint64 public nextEpochToBuild; uint256 public totalDeposited; uint256 public totalPaid; enum Status { Uninitialized, Building, AwaitingRandomness, Claimable, Paid, Empty } struct Round { uint256 pot; uint256 cursor; uint256 holderCount; uint256 totalTickets; uint64 beaconRound; Status status; address winner; uint256 winningTicket; } struct TicketRange { address holder; uint256 upperBound; } mapping(uint64 => Round) private _rounds; mapping(uint64 => TicketRange[]) private _ranges; mapping(uint64 => bytes32) public roundRandomness; mapping(uint64 => bytes32) public ticketCommitment; mapping(uint64 => uint256) public payoutAttempts; mapping(uint64 => uint256) public lastPayoutAttempt; error InvalidConfiguration(); error LotteryNotStarted(); error RoundNotClosed(); error OutOfOrderEpoch(uint64 expected); error InvalidBatchSize(); error InvalidStatus(); error RandomnessUnavailable(); error RandomnessNotDue(); error NotWinner(); error InvalidRecipient(); error PaymentFailed(); error EmptyDeposit(); error InsufficientPayoutGas(uint256 available, uint256 required); event FeesDeposited(uint64 indexed epoch, address indexed sender, uint256 amount); event RoundLocked(uint64 indexed epoch, uint256 pot, uint256 holderCount, uint64 beaconRound); event TicketBatchBuilt(uint64 indexed epoch, uint256 processedHolders, uint256 totalTickets); event RoundReady(uint64 indexed epoch, uint256 totalTickets, uint64 beaconRound); event EmptyRoundRolledOver(uint64 indexed epoch, uint64 indexed nextEpoch, uint256 amount); event WinnerSelected(uint64 indexed epoch, address indexed winner, uint256 ticket, uint256 prize, uint64 beaconRound, bytes32 randomness); event PrizeClaimed(uint64 indexed epoch, address indexed winner, address indexed recipient, uint256 amount); event PayoutSucceeded(uint64 indexed epoch, address indexed winner, uint256 amount); event PayoutFailed(uint64 indexed epoch, address indexed winner, uint256 amount); constructor( CheckpointToken token_, IFutureRandomness randomnessAdapter_, uint256 tokensPerTicket_, uint64 beaconSafetyDelay_ ) { if (address(token_).code.length == 0 || address(randomnessAdapter_).code.length == 0 || tokensPerTicket_ == 0 || beaconSafetyDelay_ == 0) revert InvalidConfiguration(); if (token_.tokensPerTicket() != tokensPerTicket_ || !token_.isExcluded(address(this))) revert InvalidConfiguration(); uint64 genesis = randomnessAdapter_.genesisTime(); uint64 period = randomnessAdapter_.period(); if (genesis == 0 || period == 0) revert InvalidConfiguration(); token = token_; randomnessAdapter = randomnessAdapter_; tokensPerTicket = tokensPerTicket_; epochZero = token_.epochZero(); beaconSafetyDelay = beaconSafetyDelay_; beaconGenesis = genesis; beaconPeriod = period; } receive() external payable { _deposit(); } function depositFees() external payable { _deposit(); } function _deposit() private { if (msg.value == 0) revert EmptyDeposit(); uint64 epoch = currentEpoch(); _rounds[epoch].pot += msg.value; totalDeposited += msg.value; emit FeesDeposited(epoch, msg.sender, msg.value); } function currentEpoch() public view returns (uint64) { if (block.timestamp < epochZero) revert LotteryNotStarted(); return uint64((block.timestamp - epochZero) / EPOCH_DURATION); } function epochStart(uint64 epoch) public view returns (uint256) { return uint256(epochZero) + uint256(epoch) * EPOCH_DURATION; } function epochClose(uint64 epoch) public view returns (uint256) { return epochStart(epoch) + EPOCH_DURATION; } /// @notice First beacon scheduled at or after the immutable close plus safety delay. function targetBeaconRound(uint64 epoch) public view returns (uint64) { uint256 targetTime = epochClose(epoch) + beaconSafetyDelay; uint256 round = targetTime <= beaconGenesis ? 1 : (targetTime - beaconGenesis + beaconPeriod - 1) / beaconPeriod + 1; if (round > type(uint64).max) revert InvalidConfiguration(); return uint64(round); } function getRound(uint64 epoch) external view returns (Round memory) { return _rounds[epoch]; } function roundRangeCount(uint64 epoch) external view returns (uint256) { return _ranges[epoch].length; } function roundRangeAt(uint64 epoch, uint256 index) external view returns (TicketRange memory) { return _ranges[epoch][index]; } /// @notice Closes an epoch from its immutable token ticket snapshot in one bounded call. /// @dev maxHolders and cursor remain in the ABI for compatibility; no holder scan is performed. /// @dev Catch-up is sequential so an empty pot only rolls into its fixed next epoch. function buildTickets(uint64 epoch, uint256 maxHolders) external { if (maxHolders == 0 || maxHolders > MAX_BATCH_SIZE) revert InvalidBatchSize(); if (epoch != nextEpochToBuild) revert OutOfOrderEpoch(nextEpochToBuild); if (block.timestamp < epochClose(epoch)) revert RoundNotClosed(); Round storage round = _rounds[epoch]; if (round.status != Status.Uninitialized && round.status != Status.Building) revert InvalidStatus(); round.holderCount = token.holderCountBefore(uint64(epochClose(epoch))); round.cursor = round.holderCount; round.beaconRound = targetBeaconRound(epoch); round.totalTickets = token.totalTickets(epoch); ticketCommitment[epoch] = keccak256(abi.encode( block.chainid, address(this), address(token), epoch, round.pot, round.totalTickets, round.beaconRound )); emit RoundLocked(epoch, round.pot, round.holderCount, round.beaconRound); emit TicketBatchBuilt(epoch, round.cursor, round.totalTickets); nextEpochToBuild = epoch + 1; if (round.totalTickets == 0) { round.status = Status.Empty; _rounds[epoch + 1].pot += round.pot; emit EmptyRoundRolledOver(epoch, epoch + 1, round.pot); } else { round.status = Status.AwaitingRandomness; emit RoundReady(epoch, round.totalTickets, round.beaconRound); } } function finalizeRound(uint64 epoch) external nonReentrant { Round storage round = _rounds[epoch]; if (round.status != Status.AwaitingRandomness) revert InvalidStatus(); uint256 scheduledTime = uint256(beaconGenesis) + (uint256(round.beaconRound) - 1) * beaconPeriod; if (block.timestamp < scheduledTime) revert RandomnessNotDue(); (bytes32 randomness, bool available) = randomnessAdapter.getRandomness(round.beaconRound); if (!available) revert RandomnessUnavailable(); // Inputs are all fixed by deployment and the closed epoch, independent of callers. bytes32 seed = keccak256(abi.encode(randomness, block.chainid, address(this), epoch)); uint256 ticket = _uniformTicket(seed, round.totalTickets); round.winner = token.winnerAt(epoch, ticket); round.winningTicket = ticket; round.status = Status.Claimable; roundRandomness[epoch] = randomness; emit WinnerSelected(epoch, round.winner, ticket, round.pot, round.beaconRound, randomness); _attemptPayout(epoch, round); } /// @notice Anyone may retry payment, always to the recorded winner. function retryPayout(uint64 epoch) external nonReentrant returns (bool success) { Round storage round = _rounds[epoch]; if (round.status != Status.Claimable) revert InvalidStatus(); return _attemptPayout(epoch, round); } function _attemptPayout(uint64 epoch, Round storage round) private returns (bool success) { uint256 amount = round.pot; if (amount != 0) { // Cover first-attempt storage, CALL overhead, and completion after a receiver exhausts its gas. // Reverting on insufficient caller gas also prevents estimation from choosing a skipped-payment path. uint256 available = gasleft(); if (available < MIN_PAYOUT_GAS) revert InsufficientPayoutGas(available, MIN_PAYOUT_GAS); } round.status = Status.Paid; totalPaid += amount; success = true; if (amount != 0) { payoutAttempts[epoch] += 1; lastPayoutAttempt[epoch] = block.timestamp; (success,) = payable(round.winner).call{value: amount, gas: AUTO_PAYOUT_GAS_LIMIT}(""); } if (success) { emit PrizeClaimed(epoch, round.winner, round.winner, amount); emit PayoutSucceeded(epoch, round.winner, amount); } else { round.status = Status.Claimable; totalPaid -= amount; emit PayoutFailed(epoch, round.winner, amount); } } /// @notice Winner-only recovery for receivers that cannot accept the bounded automatic payment. function claim(uint64 epoch, address payable recipient) external nonReentrant { Round storage round = _rounds[epoch]; if (round.status != Status.Claimable) revert InvalidStatus(); if (msg.sender != round.winner) revert NotWinner(); if (recipient == address(0) || recipient == address(this)) revert InvalidRecipient(); round.status = Status.Paid; totalPaid += round.pot; (bool success,) = recipient.call{value: round.pot}(""); if (!success) revert PaymentFailed(); emit PrizeClaimed(epoch, round.winner, recipient, round.pot); } function _uniformTicket(bytes32 seed, uint256 count) private pure returns (uint256) { uint256 threshold; unchecked { threshold = (0 - count) % count; } uint256 candidate = uint256(seed); uint256 counter; // Rejection removes modulo bias. Supply <= 2^192 makes rejection probability < 2^-64. while (candidate < threshold) { candidate = uint256(keccak256(abi.encode(seed, ++counter))); } return candidate % count; } }