// SPDX-License-Identifier: MIT pragma solidity ^0.8.30; import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {IFutureRandomness} from "./randomness/IFutureRandomness.sol"; /// @notice Automatic wallet-holder draws using publisher-attested Arc token snapshots. /// @dev The publisher is trusted for complete and correct Transfer replay, block anchors, /// and data availability. Merkle proofs verify membership and weight, not ERC20 history. /// Runtime hashes do not authenticate proxy implementations. Roots cannot be replaced, /// and each root must be committed before its predetermined future drand beacon. contract ArcSnapshotLottery is ReentrancyGuard { uint256 public constant CHAIN_ID = 5042; uint64 public constant EPOCH_DURATION = 300; uint256 public constant EXPECTED_SUPPLY = 1_000_000_000 ether; uint256 public constant TICKET_UNIT = 100_000 ether; uint256 public constant MAX_TOTAL_TICKETS = 10000; uint256 public constant MAX_EXCLUSIONS = 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; bytes32 public constant SNAPSHOT_DOMAIN = keccak256("POTLET_ARC_TICKET_SNAPSHOT_V1"); IFutureRandomness public immutable randomnessAdapter; address public immutable initializer; address public immutable publisher; uint64 public immutable snapshotWindow; uint64 public immutable beaconDelay; uint64 public immutable beaconGenesis; uint64 public immutable beaconPeriod; IERC20Metadata public token; bytes32 public tokenCodeHash; uint256 public tokensPerTicket; uint64 public epochZero; uint64 public nextEpochToBuild; bytes32 public exclusionsHash; mapping(address => bool) public isExcluded; address[] private _excludedAddresses; uint256 public totalDeposited; uint256 public totalPaid; enum Status { Uninitialized, Building, AwaitingRandomness, Claimable, Paid, Empty, Skipped } struct Round { uint256 pot; uint256 cursor; uint256 holderCount; uint256 totalTickets; uint64 beaconRound; Status status; address winner; uint256 winningTicket; } struct Snapshot { uint64 openingBlockNumber; bytes32 openingBlockHash; uint64 closingBlockNumber; bytes32 closingBlockHash; } struct ProofNode { bytes32 hash; uint256 sum; } struct TicketProof { uint256 index; address holder; uint256 eligibleBalance; uint256 count; ProofNode[] siblings; } mapping(uint64 => Round) private _rounds; mapping(uint64 => Snapshot) private _snapshots; mapping(uint64 => bytes32) public snapshotContextHash; mapping(uint64 => bytes32) public snapshotRoot; mapping(uint64 => bytes32) public snapshotDataHash; mapping(uint64 => bytes32) public ticketCommitment; mapping(uint64 => bytes32) public roundRandomness; mapping(uint64 => uint256) public payoutAttempts; mapping(uint64 => uint256) public lastPayoutAttempt; error InvalidConfiguration(); error NotInitializer(); error AlreadyActivated(); error NotActivated(); error InvalidToken(); error TokenRuntimeChanged(); error InvalidExclusions(); error LotteryNotStarted(); error EmptyDeposit(); error NotPublisher(); error OutOfOrderEpoch(uint64 expected); error RoundNotClosed(); error SnapshotDeadlinePassed(); error SnapshotDeadlineNotPassed(); error InvalidSnapshot(); error InvalidStatus(); error InvalidTicketProof(); error WrongWinningTicket(); error RandomnessNotDue(); error RandomnessUnavailable(); error NotWinner(); error InvalidRecipient(); error PaymentFailed(); error InsufficientPayoutGas(uint256 available, uint256 required); event TokenActivated(address indexed token, bytes32 runtimeCodeHash, uint256 tokensPerTicket, uint64 epochZero, bytes32 exclusionsHash); event FeesDeposited(uint64 indexed epoch, address indexed sender, uint256 amount); event SnapshotPublished(uint64 indexed epoch, bytes32 contextHash, bytes32 root, uint256 totalTickets, uint256 holderCount, bytes32 dataHash, uint64 beaconRound); event RoundLocked(uint64 indexed epoch, uint256 pot, uint256 holderCount, uint64 beaconRound); event RoundReady(uint64 indexed epoch, uint256 totalTickets, uint64 beaconRound); event EmptyRoundRolledOver(uint64 indexed epoch, uint64 indexed nextEpoch, uint256 amount); event SnapshotSkipped(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(IFutureRandomness verifier_, address initializer_, address publisher_, uint64 snapshotWindow_, uint64 beaconDelay_) { if (block.chainid != CHAIN_ID || address(verifier_).code.length == 0 || initializer_ == address(0) || publisher_ == address(0) || initializer_ == address(this) || publisher_ == address(this) || snapshotWindow_ == 0 || beaconDelay_ == 0) { revert InvalidConfiguration(); } uint64 genesis = verifier_.genesisTime(); uint64 period = verifier_.period(); if (genesis == 0 || period == 0) revert InvalidConfiguration(); randomnessAdapter = verifier_; initializer = initializer_; publisher = publisher_; snapshotWindow = snapshotWindow_; beaconDelay = beaconDelay_; beaconGenesis = genesis; beaconPeriod = period; } /// @notice One-time token binding; the canonical exclusions include zero, dead, token and lottery. function activateToken(address token_, bytes32 expectedRuntimeHash, uint256 expectedSupply, uint256 expectedUnit, uint64 epochZero_, address[] calldata exclusions) external nonReentrant { if (msg.sender != initializer) revert NotInitializer(); if (address(token) != address(0)) revert AlreadyActivated(); if (token_.code.length == 0 || token_ == address(this) || token_ == address(randomnessAdapter) || token_.codehash != expectedRuntimeHash) revert InvalidToken(); if (expectedSupply != EXPECTED_SUPPLY || expectedUnit != TICKET_UNIT || epochZero_ <= block.timestamp || epochZero_ % EPOCH_DURATION != 0) revert InvalidConfiguration(); IERC20Metadata candidate = IERC20Metadata(token_); if (candidate.decimals() != 18 || candidate.totalSupply() != expectedSupply) revert InvalidToken(); _setExclusions(token_, exclusions); token = candidate; tokenCodeHash = expectedRuntimeHash; tokensPerTicket = expectedUnit; epochZero = epochZero_; emit TokenActivated(token_, expectedRuntimeHash, expectedUnit, epochZero_, exclusionsHash); } function _setExclusions(address token_, address[] calldata extras) private { if (extras.length > MAX_EXCLUSIONS) revert InvalidExclusions(); address[] memory values = new address[](extras.length + 4); values[0] = address(0); values[1] = address(0xdead); values[2] = token_; values[3] = address(this); for (uint256 i; i < extras.length; ++i) values[i + 4] = extras[i]; for (uint256 i = 1; i < values.length; ++i) { address value = values[i]; uint256 j = i; while (j > 0 && values[j - 1] > value) { values[j] = values[j - 1]; --j; } values[j] = value; } for (uint256 i; i < values.length; ++i) { if (i != 0 && values[i] == values[i - 1]) continue; isExcluded[values[i]] = true; _excludedAddresses.push(values[i]); } if (_excludedAddresses.length > MAX_EXCLUSIONS) revert InvalidExclusions(); exclusionsHash = keccak256(abi.encode(_excludedAddresses)); } function getExcludedAddresses() external view returns (address[] memory) { return _excludedAddresses; } function getRound(uint64 epoch) external view returns (Round memory) { return _rounds[epoch]; } function getSnapshot(uint64 epoch) external view returns (Snapshot memory) { return _snapshots[epoch]; } 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; } function commitDeadline(uint64 epoch) public view returns (uint256) { return epochClose(epoch) + snapshotWindow; } function currentEpoch() public view returns (uint64) { if (address(token) == address(0)) revert NotActivated(); if (block.timestamp < epochZero) revert LotteryNotStarted(); return uint64((block.timestamp - epochZero) / EPOCH_DURATION); } function targetBeaconRound(uint64 epoch) public view returns (uint64) { uint256 target = commitDeadline(epoch) + beaconDelay; uint256 result = target <= beaconGenesis ? 1 : (target - beaconGenesis + beaconPeriod - 1) / beaconPeriod + 1; if (result > type(uint64).max) revert InvalidConfiguration(); return uint64(result); } receive() external payable { _deposit(); } function depositFees() external payable { _deposit(); } function _deposit() private { _checkToken(); if (msg.value == 0) revert EmptyDeposit(); uint64 epoch = currentEpoch(); _rounds[epoch].pot += msg.value; totalDeposited += msg.value; emit FeesDeposited(epoch, msg.sender, msg.value); } /// @dev The supplied RPC block anchors are attested metadata, not onchain-verified history. function publishSnapshot(uint64 epoch, Snapshot calldata descriptor, bytes32 root, uint256 totalTickets, uint256 holderCount, bytes32 dataHash) external nonReentrant { if (msg.sender != publisher) revert NotPublisher(); _checkToken(); _checkNextEpoch(epoch); if (block.timestamp < epochClose(epoch)) revert RoundNotClosed(); if (block.timestamp >= commitDeadline(epoch)) revert SnapshotDeadlinePassed(); if (descriptor.openingBlockNumber > descriptor.closingBlockNumber || descriptor.openingBlockHash == bytes32(0) || descriptor.closingBlockHash == bytes32(0) || root == bytes32(0) || dataHash == bytes32(0) || totalTickets > MAX_TOTAL_TICKETS || holderCount > totalTickets || (holderCount == 0) != (totalTickets == 0)) revert InvalidSnapshot(); bytes32 context = _contextHash(epoch, descriptor); if (holderCount == 0 && root != _leafHash(context, 0, address(0), 0, 0)) revert InvalidSnapshot(); Round storage round = _rounds[epoch]; round.cursor = holderCount; round.holderCount = holderCount; round.totalTickets = totalTickets; round.beaconRound = targetBeaconRound(epoch); _snapshots[epoch] = descriptor; snapshotContextHash[epoch] = context; snapshotRoot[epoch] = root; snapshotDataHash[epoch] = dataHash; ticketCommitment[epoch] = keccak256(abi.encode(context, root, totalTickets, holderCount, dataHash, round.pot, round.beaconRound)); nextEpochToBuild = epoch + 1; emit SnapshotPublished(epoch, context, root, totalTickets, holderCount, dataHash, round.beaconRound); emit RoundLocked(epoch, round.pot, holderCount, round.beaconRound); if (holderCount == 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, totalTickets, round.beaconRound); } } /// @notice Deterministically skips a missed deadline; no root or new beacon can be chosen afterward. function skipExpiredEpoch(uint64 epoch) external nonReentrant { if (address(token) == address(0)) revert NotActivated(); _checkNextEpoch(epoch); if (block.timestamp < commitDeadline(epoch)) revert SnapshotDeadlineNotPassed(); Round storage round = _rounds[epoch]; round.status = Status.Skipped; round.beaconRound = targetBeaconRound(epoch); nextEpochToBuild = epoch + 1; _rounds[epoch + 1].pot += round.pot; emit SnapshotSkipped(epoch, epoch + 1, round.pot); } function _checkNextEpoch(uint64 epoch) private view { if (epoch != nextEpochToBuild) revert OutOfOrderEpoch(nextEpochToBuild); if (_rounds[epoch].status != Status.Uninitialized) revert InvalidStatus(); } function _checkToken() private view { if (address(token) == address(0)) revert NotActivated(); if (address(token).codehash != tokenCodeHash) revert TokenRuntimeChanged(); } function _contextHash(uint64 epoch, Snapshot calldata descriptor) private view returns (bytes32) { return keccak256(abi.encode(SNAPSHOT_DOMAIN, block.chainid, address(this), address(token), epochZero, tokensPerTicket, epoch, descriptor.openingBlockNumber, descriptor.openingBlockHash, descriptor.closingBlockNumber, descriptor.closingBlockHash, exclusionsHash)); } function _leafHash(bytes32 context, uint256 index, address holder, uint256 balance, uint256 count) private pure returns (bytes32) { return keccak256(abi.encode(uint8(0), context, index, holder, balance, count)); } /// @notice Verifies only membership, ticket weight and cumulative interval in the published list. function verifyTicketProof(uint64 epoch, TicketProof calldata proof) public view returns (address holder, uint256 firstTicket, uint256 count) { Round storage round = _rounds[epoch]; if (round.holderCount == 0 || proof.index >= round.holderCount || proof.holder == address(0) || isExcluded[proof.holder] || proof.count == 0 || proof.count > MAX_TOTAL_TICKETS || proof.eligibleBalance > EXPECTED_SUPPLY || proof.count != proof.eligibleBalance / tokensPerTicket) { revert InvalidTicketProof(); } uint256 depth; for (uint256 width = 1; width < round.holderCount; width *= 2) ++depth; if (proof.siblings.length != depth) revert InvalidTicketProof(); bytes32 node = _leafHash(snapshotContextHash[epoch], proof.index, proof.holder, proof.eligibleBalance, proof.count); uint256 sum = proof.count; uint256 cursor = proof.index; for (uint256 i; i < depth; ++i) { ProofNode calldata sibling = proof.siblings[i]; if (sibling.sum > MAX_TOTAL_TICKETS || sum + sibling.sum > MAX_TOTAL_TICKETS) revert InvalidTicketProof(); if (cursor % 2 == 1) { firstTicket += sibling.sum; node = keccak256(abi.encode(uint8(1), sibling.hash, sibling.sum, node, sum)); } else node = keccak256(abi.encode(uint8(1), node, sum, sibling.hash, sibling.sum)); sum += sibling.sum; cursor /= 2; } if (node != snapshotRoot[epoch] || sum != round.totalTickets || firstTicket + proof.count > sum) { revert InvalidTicketProof(); } return (proof.holder, firstTicket, proof.count); } function finalizeRound(uint64 epoch, TicketProof calldata proof) external nonReentrant { _checkToken(); Round storage round = _rounds[epoch]; if (round.status != Status.AwaitingRandomness) revert InvalidStatus(); uint256 scheduled = uint256(beaconGenesis) + (uint256(round.beaconRound) - 1) * beaconPeriod; if (block.timestamp < scheduled) revert RandomnessNotDue(); (bytes32 randomness, bool available) = randomnessAdapter.getRandomness(round.beaconRound); if (!available) revert RandomnessUnavailable(); bytes32 seed = keccak256(abi.encode(randomness, block.chainid, address(this), epoch)); uint256 ticket = _uniformTicket(seed, round.totalTickets); (address holder, uint256 first, uint256 count) = verifyTicketProof(epoch, proof); if (ticket < first || ticket - first >= count) revert WrongWinningTicket(); round.winner = holder; round.winningTicket = ticket; round.status = Status.Claimable; roundRandomness[epoch] = randomness; emit WinnerSelected(epoch, holder, ticket, round.pot, round.beaconRound, randomness); _attemptPayout(epoch, round); } 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 && gasleft() < MIN_PAYOUT_GAS) revert InsufficientPayoutGas(gasleft(), 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); } } 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); emit PayoutSucceeded(epoch, round.winner, 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; while (candidate < threshold) candidate = uint256(keccak256(abi.encode(seed, ++counter))); return candidate % count; } }