// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import {IPonsV2FeeEscrow} from "./IPonsV2FeeEscrow.sol"; import {SeedSource} from "../SeedSource.sol"; interface IERC20Min { function balanceOf(address account) external view returns (uint256); function transfer(address to, uint256 amount) external returns (bool); } /// @title MegaDraw v3 — the hourly holder draw for a token that has no hooks (a Pons V2 launch) /// @notice Every rule below is fixed when the contract is deployed. The owner can only exclude addresses /// (pools, lockers) and then lock(); no round can run before lock(), and after it nobody can change, /// pause, upgrade or withdraw anything. /// /// Registry Anyone can register a wallet holding at least one ticket (50,000 tokens). The registry is /// capped (maxHolders) so a full read always fits in one transaction. When it is full, a wallet /// that no longer holds a ticket — or holds fewer whole tickets than a newcomer — can be /// replaced, so a full list always belongs to the largest holders. The cap itself can be raised /// by anyone, but only as far as the gas the checkpoints measured on themselves proves safe /// (raiseCap), and never past ABS_MAX_HOLDERS. If reads ever stop fitting a transaction, the /// freeze escape (pruneFrozen) lets anyone shrink the list until rounds run again. /// Checkpoints In the last minutes before each hour the balance of EVERY registered wallet is read four /// times, each read inside a single transaction (nothing can move between two reads of one /// transaction, so no token is counted twice). Each checkpoint has a short slot; a missed slot /// moves the round to the next hour. Only a plain wallet (not a contract, not a delegated /// account) can take a checkpoint or call draw(), so nobody can borrow tokens around the read /// inside their own transaction. The reads are committed per chunk and emitted. /// Seed The last checkpoint fixes the prize and commits to three blocks that do not exist yet. /// Randomized The seed decides which checkpoint the count STARTS from (1st, 2nd, 3rd or 4th). A wallet's /// snapshot counted balance is its LOWEST balance from that checkpoint to the last one, so nobody knows in /// advance which moments count. A wallet whose lowest reading in the round is worth fewer whole /// tickets than its previous reading (the last checkpoint of the round before, or registration) /// sold during the round and sits this draw out. Contracts get nothing. /// Finalists Three different wallets are drawn, each draw weighted by tickets, without replacement. /// Winner One of the three is picked with equal odds. The pick must still hold the tokens its tickets /// came from when draw() runs; if not, the next finalist in draw order who does wins. If none /// does, the pot rolls over. The prize — minus the team's fixed TEAM_BPS share and a small /// caller tip — is paid in the same transaction. /// If nobody resolves the seed while its blocks are still readable (about 11 hours), the round /// is void and the pot rolls over; a result is never re-drawn with a different seed. contract MegaDraw is SeedSource { // ------------------------------------------------------------------ constants uint256 public constant TICKET = 50_000e18; uint256 internal constant ROUND_LENGTH = 1 hours; uint8 internal constant CHECKPOINTS = 4; uint8 internal constant FINALISTS = 3; uint256 public constant TEAM_BPS = 475; // 4.75% of each prize to the team wallet, fixed at deploy (with the 0.25% caller tip the team's keeper earns, 5% in all) uint256 public constant TIP_BPS = 25; // 0.25% (capped at tipCap) to whoever calls draw(), so anyone is paid to keep rounds moving uint256 internal constant PAY_GAS = 60_000; // gas for a native payout uint256 internal constant TOKEN_PAY_GAS = 200_000; // gas for an ERC-20 payout uint32 internal constant PRUNE_AFTER = 3; // rounds at 0 tickets before a wallet can be dropped from the registry uint256 internal constant RECYCLE_AFTER = 180 days; // an unclaimed credit returns to the pot after this uint32 internal constant CHUNK = 100; // wallets per committed chunk uint32 internal constant ABS_MAX_HOLDERS = 5000; // no cap raise may pass this: one checkpoint must always fit the chain's 32M-gas transaction limit uint32 internal constant GAS_FLOOR = 6_190; // cold gas one wallet's read costs on this chain, measured on-chain; a self-measurement can veto a raise but never promise below this (an access list can pre-warm storage and fake a cheap read) uint256 internal constant RAISE_BUDGET = 30_500_000; // newCap * per-wallet gas + 500k must fit here (the chain refuses transactions over 32M) uint32 internal constant FROZEN_ROUNDS = 72; // missed rounds in a row before the freeze escape opens uint256 internal constant FROZEN_TIME = 3 days; // and at least this long since a round last completed uint256 internal constant STUCK_TIME = 7 days; // a round stuck mid-flight this long may be voided by anyone // domain tags: every number derived from a round's seed is keccak256(abi.encode(seed, this, roundId, tag, j)) uint8 internal constant TAG_START = 1; // j = 0; % CHECKPOINTS = the checkpoint the count starts from uint8 internal constant TAG_FINALIST = 2; // j = 0, 1, 2; % tickets still in play = the j-th finalist's ticket uint8 internal constant TAG_PICK = 3; // j = 0; % finalists = the finalist the final spin lands on // ------------------------------------------------------------------ immutables address public immutable token; // the ERC-20 whose holders play (a Pons V2 launcher token) address public immutable prizeToken; // the pairing asset the fees arrive in; address(0) = native ETH address public immutable escrow; // Pons V2 fee escrow, or address(0) when fees are sent directly address public immutable teamWallet; // receives TEAM_BPS of each prize; address(0) = no team fee uint256 public immutable tipCap; // in prize-asset units uint64 public immutable closeOffset; // the last checkpoint is due this many seconds before the top of the hour uint64 public immutable windowLength; // seconds from the first checkpoint to the last // ---- the registry cap: starts at the deploy value and can only be RAISED, permissionlessly, and only as // ---- far as the chain's own measured gas allows (see raiseCap). Never past ABS_MAX_HOLDERS. uint32 public maxHolders; // registry cap, sized so one checkpoint read fits a transaction uint32 public lastGasPerWallet; // measured by checkpoint() itself on a real full read (veto only, see raiseCap) uint32 public missedStreak; // checkpoint slots missed in a row (a completed round resets it) uint64 public lastCompletedAt; // when a round last completed (drawn, rolled over or voided) uint32 private evictedThisRound; // holders evicted by a larger wallet this round (rate-limited) // ------------------------------------------------------------------ setup (before lock) address public owner; bool public locked; mapping(address => bool) public excluded; // never hold tickets (set before lock) // ------------------------------------------------------------------ registry mapping(address => uint32) public indexOf; // 1-based mapping(uint32 => address) public holderAt; uint32 public holderCount; struct Slot { uint64 prefixBefore; // cumulative tickets before this wallet, this round uint64 tickets; // tickets this round (0 when sitting out) uint120 lastBalance; // balance at the last checkpoint of the previous round (or at registration) bool satOut; // a reading this round was worth fewer whole tickets than lastBalance uint32 zeroStreak; // consecutive rounds at 0 tickets } mapping(uint32 => Slot) private slots; // ------------------------------------------------------------------ round state enum Phase { Open, Seeding, Writing, Drawing } Phase public phase; uint64 public roundId = 1; uint64 public epoch; // rounds whose tickets were fully written uint64 public roundEndsAt; // the top of the hour this round is drawn for uint8 public checkpointsDone; uint32[4] private cpCount; // registry size at each checkpoint (never decreases within a round) uint64[4] private cpBlock; uint64[4] private cpTime; mapping(uint8 => mapping(uint32 => bytes32)) public checkpointHash; // keccak256 of the balances read for (checkpoint, chunk) uint64 public commitBlock; uint64 public seedBlock; bytes32 public roundSeed; uint8 public startCheckpoint; uint32 public writeCursor; // wallets written so far uint64 public snapshotTotal; // running / final total of tickets uint32 public snapshotWallets; // wallets with at least one ticket uint256 public roundPrize; // fixed at the last checkpoint; later arrivals belong to the next round struct Result { address winner; // 0 when rolled over uint32 winnerIndex; uint64 winnerTickets; uint64 totalTickets; uint32 wallets; // wallets holding tickets uint8 startCheckpoint; uint8 finalistCount; // min(3, wallets) uint8 pick; // the finalist the final spin landed on uint8 winnerSlot; // the finalist who was paid (pick, or the next eligible one) address[3] finalists; // in draw order uint32[3] finalistIndex; uint64[3] finalistTickets; uint64[4] checkpointBlocks; uint64[4] checkpointTimes; uint64 commitBlock; uint64 seedBlock; uint64 drawBlock; bytes32 seedHash; uint256 prize; // paid to the winner (0 when rolled over) uint256 tip; bool rolledOver; bool forfeited; // finalists were drawn but none still held their tokens bool voided; // the seed was never resolved while readable; nothing was drawn } mapping(uint64 => Result) private results; mapping(address => uint256) public claimable; mapping(address => uint64) public creditedAt; uint256 public claimableTotal; uint256 private reentrancy = 1; // ------------------------------------------------------------------ events event HolderAdded(address indexed holder, uint32 index, uint256 balance); event HolderReplaced(address indexed removed, address indexed added, uint32 index, uint256 balance); event HolderPruned(address indexed holder, uint32 index); event Excluded(address indexed account, bool excluded); event Locked(); event Swept(uint256 amount); event Funded(address indexed from, uint256 amount); /// A checkpoint slot was missed (`missed` is the checkpoint that came too late); the round starts over for the next hour. event RoundRescheduled(uint64 indexed roundId, uint64 roundEndsAt, uint8 missed); event CheckpointTaken(uint64 indexed roundId, uint8 checkpoint, uint32 wallets, uint64 blockNumber, uint64 timestamp); /// The balances read at `checkpoint` for wallets fromIndex .. fromIndex + balances.length - 1; write() takes them back as calldata. event BalancesRead(uint64 indexed roundId, uint8 indexed checkpoint, uint32 chunk, uint32 fromIndex, uint256[] balances); event SeedCommitted(uint64 indexed roundId, uint64 commitBlock, uint64 seedBlock, uint256 prize); event RoundVoided(uint64 indexed roundId, uint64 seedBlock); event SeedResolved(uint64 indexed roundId, bytes32 seedHash, uint8 startCheckpoint); /// countedBalance: lowest balance over the counted checkpoints (0 when late); previousBalance: the reading it is compared with. event Snapshotted(uint64 indexed roundId, address indexed holder, uint32 index, uint64 tickets, uint256 countedBalance, uint256 previousBalance, bool satOut, bool late); event FinalistChecked(uint64 indexed roundId, uint8 slot, address indexed finalist, uint256 balance, bool eligible); event RoundDrawn(uint64 indexed roundId, address indexed winner, uint256 prize, uint256 tip, address[3] finalists, uint8 pick, uint8 winnerSlot, uint64 totalTickets, bytes32 seedHash); event TeamPaid(uint64 indexed roundId, address indexed to, uint256 amount); event CapRaised(uint32 newCap, uint32 gasPerWallet); event RoundRolledOver(uint64 indexed roundId, uint64 totalTickets, uint256 prize, bool forfeited); event PayoutCredited(address indexed to, uint256 amount); event Claimed(address indexed to, uint256 amount); event Recycled(address indexed from, uint256 amount); // ------------------------------------------------------------------ errors error NotOwner(); error NotLocked(); error ZeroAddress(); error BadLimit(); error WrongPhase(); error TooEarly(); error NothingToDo(); error BadChunk(); error Reentrancy(); error NothingToClaim(); error PayoutFailed(); error NotPrunable(); error NotReplaceable(); error NotRecyclable(); error SeedUnavailable(); error LowGas(); error NativeNotAccepted(); error NotPlainWallet(); modifier nonReentrant() { if (reentrancy != 1) revert Reentrancy(); reentrancy = 2; _; reentrancy = 1; } /// Balance reads happen only in a transaction a plain wallet sent straight to this contract, so no caller can /// wrap a read in its own code (borrow tokens, get read, return them) within one transaction. modifier onlyPlainWallet() { if (msg.sender != tx.origin || msg.sender.code.length != 0) revert NotPlainWallet(); _; } /// @param closeOffset_ the last checkpoint is due this many seconds before the top of the hour (180); /// pick seedDelay so the seed blocks arrive about at the hour /// @param windowLength_ seconds from the first checkpoint to the last (420: checkpoints at 10:00, 7:40, 5:20, 3:00) /// @param maxHolders_ starting registry cap; a checkpoint costs about 6,200 gas per registered wallet /// @param team_ receives TEAM_BPS of every prize; address(0) turns the team fee off for good constructor(address token_, address prizeToken_, address escrow_, bool arbitrum_, uint64 seedDelay_, uint64 seedStep_, uint256 tipCap_, uint64 closeOffset_, uint64 windowLength_, uint32 maxHolders_, address team_) SeedSource(arbitrum_, seedDelay_, seedStep_) { if (token_ == address(0)) revert ZeroAddress(); if (windowLength_ < CHECKPOINTS - 1 || closeOffset_ + windowLength_ + windowLength_ / (CHECKPOINTS - 1) >= ROUND_LENGTH / 2) revert BadDelay(); if (maxHolders_ == 0 || maxHolders_ > ABS_MAX_HOLDERS) revert BadLimit(); token = token_; prizeToken = prizeToken_; escrow = escrow_; teamWallet = team_; tipCap = tipCap_; closeOffset = closeOffset_; windowLength = windowLength_; maxHolders = maxHolders_; owner = msg.sender; excluded[address(this)] = true; excluded[token_] = true; if (escrow_ != address(0)) excluded[escrow_] = true; if (team_ != address(0)) excluded[team_] = true; // the team can never hold tickets in its own draw roundEndsAt = _nextRoundEnd(); } receive() external payable { if (prizeToken != address(0)) revert NativeNotAccepted(); emit Funded(msg.sender, msg.value); } // ------------------------------------------------------------------ setup /// Exclude an address from ever holding tickets (a pool, a locker). Only before lock(). function setExcluded(address account, bool value) external { if (msg.sender != owner) revert NotOwner(); if (account == address(0)) revert ZeroAddress(); excluded[account] = value; emit Excluded(account, value); } /// Give up the only power this contract has. One way. Registration and rounds start here, so no /// wallet can be registered and no round can run before the exclusions are final. function lock() external { if (msg.sender != owner) revert NotOwner(); locked = true; owner = address(0); roundEndsAt = _nextRoundEnd(); lastCompletedAt = uint64(block.timestamp); // the freeze clock starts here emit Locked(); } // ------------------------------------------------------------------ registry (permissionless) /// Add wallets that hold at least one ticket. Contracts, excluded addresses and duplicates are skipped, /// and so is everything past the cap. The balance seen here is the wallet's baseline. Only while the /// round is open; a wallet added between checkpoints only counts from the checkpoints that read it. function register(address[] calldata wallets) external { if (!locked) revert NotLocked(); if (phase != Phase.Open) revert WrongPhase(); uint32 n = holderCount; uint32 cap = maxHolders; for (uint256 i; i < wallets.length && n < cap; ++i) { address w = wallets[i]; if (!_admissible(w)) continue; uint256 bal = IERC20Min(token).balanceOf(w); if (bal < TICKET) continue; uint32 idx = ++n; indexOf[w] = idx; holderAt[idx] = w; slots[idx].lastBalance = uint120(bal); // the slot is empty: prune() and replace() clear the slots they free emit HolderAdded(w, idx, bal); } holderCount = n; } /// When the registry is full, put wallets[i] (holding at least one ticket) in the place of victims[i]: a /// registered wallet that holds less than one ticket now (or has become a contract), or one holding /// strictly fewer whole tickets than the incoming wallet — a full list always belongs to the largest /// holders, so the wheel never turns a big buyer away. Pairs that do not qualify are skipped. Only while /// the round is open and before its first checkpoint, because it changes who sits at an index. Returns how /// many were replaced. function replace(address[] calldata wallets, address[] calldata victims) external returns (uint256 done) { if (!locked) revert NotLocked(); if (phase != Phase.Open || checkpointsDone != 0) revert WrongPhase(); if (wallets.length != victims.length) revert NotReplaceable(); if (holderCount < maxHolders) revert NotReplaceable(); // there is room: use register() for (uint256 i; i < wallets.length; ++i) { address wallet = wallets[i]; address victim = victims[i]; uint32 idx = indexOf[victim]; if (idx == 0 || !_admissible(wallet)) continue; uint256 bal = IERC20Min(token).balanceOf(wallet); if (bal < TICKET) continue; uint256 vbal = _isContract(victim) ? 0 : IERC20Min(token).balanceOf(victim); if (vbal >= TICKET) { if (bal / TICKET <= vbal / TICKET) continue; // the victim keeps its place against an equal or smaller wallet // evicting a wallet that still holds a ticket is rate-limited, so one bag of tokens hopping // between fresh wallets cannot churn honest holders out of the whole list in one round if (evictedThisRound >= maxHolders / 50 + 1) continue; unchecked { ++evictedThisRound; } } delete indexOf[victim]; delete slots[idx]; indexOf[wallet] = idx; holderAt[idx] = wallet; slots[idx].lastBalance = uint120(bal); emit HolderReplaced(victim, wallet, idx, bal); ++done; } } /// Drop wallets that have sat at 0 tickets for PRUNE_AFTER rounds and hold less than a ticket now (or have /// become contracts), so reads stay cheap. Others are skipped. Only while the round is open and before its /// first checkpoint. Returns how many were dropped. function prune(address[] calldata wallets) external returns (uint256 done) { if (phase != Phase.Open || checkpointsDone != 0) revert WrongPhase(); for (uint256 i; i < wallets.length; ++i) { address wallet = wallets[i]; uint32 idx = indexOf[wallet]; if (idx == 0 || slots[idx].zeroStreak < PRUNE_AFTER) continue; if (!_isContract(wallet) && IERC20Min(token).balanceOf(wallet) >= TICKET) continue; _remove(idx, wallet); emit HolderPruned(wallet, idx); ++done; } } /// Raise the registry cap. Anyone can call, and it can only ever go UP — but never past ABS_MAX_HOLDERS, /// and never past what fits one transaction: newCap wallets at the cold per-wallet read cost must fit /// RAISE_BUDGET (the chain refuses transactions over 32M). The cost used is the WORSE of the hardcoded /// cold floor and what the last full read measured on itself — the measurement can only veto a raise, /// never promise one, so pre-warming storage with an access list to fake a cheap read achieves nothing. /// Only between rounds (open, nothing read yet), so a raise can never outgrow a round mid-flight. /// No owner, no keys: the cap grows exactly as far as physics allows, and if the chain ever reprices gas /// upward, the next real read's measurement shrinks what can be promised. function raiseCap(uint32 newCap) external { if (!locked) revert NotLocked(); if (phase != Phase.Open || checkpointsDone != 0) revert WrongPhase(); if (newCap <= maxHolders || newCap > ABS_MAX_HOLDERS) revert BadLimit(); uint32 g = lastGasPerWallet < GAS_FLOOR ? GAS_FLOOR : lastGasPerWallet; if (uint256(newCap) * g + 500_000 > RAISE_BUDGET) revert BadLimit(); maxHolders = newCap; emit CapRaised(newCap, g); } /// The freeze escape. If a full read stops fitting in a transaction (say the chain reprices gas after a /// cap raise), every checkpoint slot is missed and the round reschedules for ever — with no owner, nobody /// could fix it. So: after FROZEN_ROUNDS missed slots in a row AND FROZEN_TIME without a completed round, /// anyone may drop the smallest wallets — those under a balance floor that starts at 2 tickets and doubles /// for every further frozen day — until reads fit again and rounds resume. A dropped wallet loses only its /// place in the list, never tokens, and can register again at any time. function pruneFrozen(address[] calldata wallets) external returns (uint256 done) { if (!locked) revert NotLocked(); if (phase != Phase.Open || checkpointsDone != 0) revert WrongPhase(); uint256 frozenFor = block.timestamp - lastCompletedAt; if (missedStreak < FROZEN_ROUNDS || frozenFor < FROZEN_TIME) revert NotPrunable(); // day 3 -> 2 tickets, day 4 -> 4, day 5 -> 8, day 6 on -> 16, never more: wallets of 16+ tickets can // never be shed this way, and the supply caps how many of those can exist, so shedding everything // smaller always makes a full read fit again. The escape can trim dust, never the honest middle. uint256 exp = frozenFor / 1 days - 2; if (exp > 4) exp = 4; uint256 minBalance = (uint256(1) << exp) * TICKET; for (uint256 i; i < wallets.length; ++i) { address wallet = wallets[i]; uint32 idx = indexOf[wallet]; if (idx == 0) continue; if (!_isContract(wallet) && IERC20Min(token).balanceOf(wallet) >= minBalance) continue; _remove(idx, wallet); emit HolderPruned(wallet, idx); ++done; } } // ------------------------------------------------------------------ the hourly round /// Take the next checkpoint: read the balance of every registered wallet in this one transaction and /// commit to them. Any plain wallet can call inside the checkpoint's slot, [checkpointDue(), checkpointDeadline()]. /// The last checkpoint fixes the prize and commits to the seed blocks. Called after a slot has passed, the /// round's checkpoints are discarded and the round moves to the next hour instead (nothing is read), so /// nobody can hold a checkpoint back to a moment of their choosing. function checkpoint() external onlyPlainWallet { if (!locked) revert NotLocked(); if (phase != Phase.Open) revert WrongPhase(); uint8 c = checkpointsDone; uint256 due = checkpointDue(); if (block.timestamp < due) revert TooEarly(); uint64 rid = roundId; if (block.timestamp > due + checkpointGrace()) { checkpointsDone = 0; roundEndsAt = _nextRoundEnd(); unchecked { ++missedStreak; } // the freeze escape watches this; any completed round resets it emit RoundRescheduled(rid, roundEndsAt, c); return; } uint32 n = holderCount; uint64 bn = uint64(currentBlock()); cpCount[c] = n; cpBlock[c] = bn; cpTime[c] = uint64(block.timestamp); checkpointsDone = c + 1; emit CheckpointTaken(rid, c, n, bn, uint64(block.timestamp)); if (n != 0) { // the read measures itself, so raiseCap() can only promise what this chain has actually delivered uint256 g0 = gasleft(); _readAll(rid, c, n); if (n >= 100) lastGasPerWallet = uint32((g0 - gasleft()) / n + 1); } if (c + 1 == CHECKPOINTS) { phase = Phase.Seeding; commitBlock = bn; seedBlock = bn + seedDelay; roundPrize = pot(); emit SeedCommitted(rid, bn, seedBlock, roundPrize); } } function _readAll(uint64 rid, uint8 c, uint32 n) private { uint256[] memory buf = new uint256[](CHUNK); uint32 chunk; uint32 k; for (uint32 i = 1; i <= n; ++i) { buf[k] = IERC20Min(token).balanceOf(holderAt[i]); unchecked { ++k; } if (k == CHUNK || i == n) { if (k != CHUNK) assembly { mstore(buf, k) } // the tail chunk is shorter checkpointHash[c][chunk] = keccak256(abi.encodePacked(buf)); emit BalancesRead(rid, c, chunk, i - k + 1, buf); unchecked { ++chunk; } k = 0; } } } /// Once the seed blocks exist: fix the seed and the checkpoint the count starts from. Anyone can call. /// A missing hash inside the chain's hash window reverts, so nobody can make a seed unreadable on purpose /// (e.g. with low gas). If the blocks have really left the window (hours of nobody calling), the round is /// void: nothing is drawn, the pot rolls over, and these tickets are never drawn with another seed. function resolve() external { if (phase != Phase.Seeding) revert WrongPhase(); uint256 sb = seedBlock; uint256 bn = currentBlock(); if (bn <= seedReadyAt(sb)) revert TooEarly(); uint64 rid = roundId; bytes32 seed = seedHashFor(sb); if (seed == bytes32(0)) { if (!seedExpired(sb)) revert SeedUnavailable(); Result storage res = results[rid]; for (uint8 c; c < CHECKPOINTS; ++c) { res.checkpointBlocks[c] = cpBlock[c]; res.checkpointTimes[c] = cpTime[c]; } res.commitBlock = commitBlock; res.seedBlock = uint64(sb); res.drawBlock = uint64(bn); _voidTail(res, rid, uint64(sb)); return; } uint8 s = uint8(_derive(seed, rid, TAG_START, 0) % CHECKPOINTS); roundSeed = seed; startCheckpoint = s; writeCursor = 0; snapshotTotal = 0; snapshotWallets = 0; emit SeedResolved(rid, seed, s); if (cpCount[CHECKPOINTS - 1] == 0) { phase = Phase.Drawing; epoch += 1; } else { phase = Phase.Writing; } } /// Write the next wallets' tickets from the committed checkpoint balances. For every chunk, in order from /// the cursor, pass the four lists the checkpoints read for it, each exactly as BalancesRead emitted it (an /// empty list where a checkpoint read none of the chunk's wallets). Anything else reverts. Anyone can call; /// the result does not depend on who writes. function write(uint256[][] calldata lists) external { if (phase != Phase.Writing) revert WrongPhase(); uint256 len = lists.length; if (len == 0 || len % CHECKPOINTS != 0) revert BadChunk(); uint32 n = cpCount[CHECKPOINTS - 1]; uint32 from = writeCursor; for (uint256 base; base < len; base += CHECKPOINTS) { if (from >= n) revert BadChunk(); uint32 to = n - from > CHUNK ? from + CHUNK : n; _verifyChunk(lists, base, from, to); _writeRange(lists, base, from, to); from = to; } writeCursor = from; if (from == n) { phase = Phase.Drawing; epoch += 1; } } function _verifyChunk(uint256[][] calldata lists, uint256 base, uint32 from, uint32 to) private view { uint32 chunk = from / CHUNK; for (uint8 c; c < CHECKPOINTS; ++c) { uint256[] calldata l = lists[base + c]; uint32 nc = cpCount[c]; uint256 expect = nc <= from ? 0 : (nc < to ? nc - from : to - from); if (l.length != expect) revert BadChunk(); if (expect != 0 && keccak256(abi.encodePacked(l)) != checkpointHash[c][chunk]) revert BadChunk(); } } function _writeRange(uint256[][] calldata lists, uint256 base, uint32 from, uint32 to) private { uint64 running = snapshotTotal; uint32 wallets = snapshotWallets; uint64 rid = roundId; for (uint32 pos = from; pos < to; ++pos) { (uint256 counted, uint256 lowest, uint256 last, bool late) = _countOne(lists, base, pos, from); uint64 t = _store(pos + 1, counted, lowest, last, late, running, rid); if (t != 0) { running += t; ++wallets; } } snapshotTotal = running; snapshotWallets = wallets; } /// The wallet at 0-based position `pos`: its lowest balance over the counted checkpoints (startCheckpoint to /// the last), its lowest balance over every checkpoint that read it, its balance at the last checkpoint, and /// whether the start checkpoint ran before it was registered. function _countOne(uint256[][] calldata lists, uint256 base, uint32 pos, uint32 from) private view returns (uint256 counted, uint256 lowest, uint256 last, bool late) { uint8 s = startCheckpoint; counted = type(uint256).max; lowest = type(uint256).max; for (uint8 c; c < CHECKPOINTS; ++c) { if (pos >= cpCount[c]) { // not registered yet when checkpoint c ran if (c >= s) late = true; continue; } uint256 b = lists[base + c][pos - from]; if (b < lowest) lowest = b; if (c >= s && b < counted) counted = b; last = b; // the last checkpoint reads every wallet, so this ends at its balance } if (late) counted = 0; } function _store(uint32 idx, uint256 counted, uint256 lowest, uint256 last, bool late, uint64 running, uint64 rid) private returns (uint64 t) { Slot storage sl = slots[idx]; address h = holderAt[idx]; uint256 previous = sl.lastBalance; // whole tickets left the wallet since its previous reading, at any checkpoint of this round bool sold = !late && lowest / TICKET < previous / TICKET; if (!late && !sold && !_isContract(h)) t = uint64(counted / TICKET); sl.prefixBefore = running; sl.tickets = t; sl.lastBalance = uint120(last); sl.satOut = sold; sl.zeroStreak = t == 0 ? sl.zeroStreak + 1 : 0; emit Snapshotted(rid, h, idx, t, counted, previous, sold, late); } /// Draw the three finalists and the winner, and pay. Anyone can call once every wallet is written; the /// caller gets the tip. The outcome was fixed by the seed; only the finalists' own balances at this moment /// (their eligibility) are read here. function draw() external nonReentrant onlyPlainWallet { if (phase != Phase.Drawing) revert WrongPhase(); uint64 rid = roundId; uint256 prize = roundPrize; uint64 total = snapshotTotal; Result storage res = results[rid]; _record(res); _openNext(rid); // the frozen tickets below stay untouched until the next round's writes if (total == 0 || prize == 0) return _rollOver(res, rid, total, prize, false); bytes32 seed = res.seedHash; uint8 k = _pickFinalists(res, seed, rid, total); uint8 pick = uint8(_derive(seed, rid, TAG_PICK, 0) % k); res.pick = pick; (bool found, uint8 slot) = _walk(res, rid, pick, k); if (!found) return _rollOver(res, rid, total, prize, true); _settle(res, rid, slot, prize); } function _rollOver(Result storage res, uint64 rid, uint64 total, uint256 prize, bool forfeited) private { res.rolledOver = true; res.forfeited = forfeited; emit RoundRolledOver(rid, total, prize, forfeited); } function _record(Result storage res) private { res.totalTickets = snapshotTotal; res.wallets = snapshotWallets; res.startCheckpoint = startCheckpoint; for (uint8 c; c < CHECKPOINTS; ++c) { res.checkpointBlocks[c] = cpBlock[c]; res.checkpointTimes[c] = cpTime[c]; } res.commitBlock = commitBlock; res.seedBlock = seedBlock; res.drawBlock = uint64(currentBlock()); res.seedHash = roundSeed; } /// Up to FINALISTS different wallets, each drawn with probability proportional to its tickets among the /// tickets not yet drawn. Ticket positions are [prefixBefore, prefixBefore + tickets); the j-th draw is a /// position among the remaining tickets, mapped back past the ranges already drawn (kept sorted). function _pickFinalists(Result storage res, bytes32 seed, uint64 rid, uint64 total) private returns (uint8 k) { k = res.wallets < FINALISTS ? uint8(res.wallets) : FINALISTS; uint256[3] memory starts; uint256[3] memory lens; uint256 removed; for (uint8 j; j < k; ++j) { uint256 r = _derive(seed, rid, TAG_FINALIST, j) % (uint256(total) - removed); for (uint8 q; q < j; ++q) { if (r >= starts[q]) r += lens[q]; else break; } uint32 idx = _find(r); Slot storage sl = slots[idx]; res.finalists[j] = holderAt[idx]; res.finalistIndex[j] = idx; res.finalistTickets[j] = sl.tickets; removed += sl.tickets; uint8 p = j; while (p > 0 && starts[p - 1] > sl.prefixBefore) { starts[p] = starts[p - 1]; lens[p] = lens[p - 1]; --p; } starts[p] = sl.prefixBefore; lens[p] = sl.tickets; } res.finalistCount = k; } /// From the pick, in draw order (wrapping), the first finalist still holding the tokens its tickets came from. function _walk(Result storage res, uint64 rid, uint8 pick, uint8 k) private returns (bool, uint8) { for (uint8 t; t < k; ++t) { uint8 slot = (pick + t) % k; address f = res.finalists[slot]; uint256 bal = IERC20Min(token).balanceOf(f); bool ok = bal >= uint256(res.finalistTickets[slot]) * TICKET; emit FinalistChecked(rid, slot, f, bal, ok); if (ok) return (true, slot); } return (false, 0); } function _settle(Result storage res, uint64 rid, uint8 slot, uint256 prize) private { address w = res.finalists[slot]; res.winner = w; res.winnerSlot = slot; res.winnerIndex = res.finalistIndex[slot]; res.winnerTickets = res.finalistTickets[slot]; uint256 team = teamWallet == address(0) ? 0 : prize * TEAM_BPS / 10_000; uint256 tip = prize * TIP_BPS / 10_000; if (tip > tipCap) tip = tipCap; res.tip = tip; res.prize = prize - team - tip; emit RoundDrawn(rid, w, prize - team - tip, tip, res.finalists, res.pick, slot, res.totalTickets, res.seedHash); if (team != 0) emit TeamPaid(rid, teamWallet, team); // enough gas left that a payout can only fail because a recipient refuses it, never because the caller starved it if (gasleft() < 3 * (prizeToken == address(0) ? PAY_GAS : TOKEN_PAY_GAS) + 200_000) revert LowGas(); _pay(w, prize - team - tip); // the winner first, so no caller games the gas against the prize _pay(teamWallet, team); _pay(msg.sender, tip); } /// If a round is stuck mid-flight (Writing or Drawing) for STUCK_TIME — meaning nobody on the whole chain /// finished it despite the open tip — anyone may void it: the pot rolls over intact and the next round /// opens. The clock is THIS round's own last checkpoint, never an earlier round's history, so a freshly /// resolved round can never be voided out from under draw() by someone who dislikes its outcome. /// A stuck Seeding round already voids itself through resolve() once its blocks leave the window. function abortFrozen() external { if (phase != Phase.Writing && phase != Phase.Drawing) revert WrongPhase(); if (block.timestamp < uint256(cpTime[CHECKPOINTS - 1]) + STUCK_TIME) revert NotPrunable(); uint64 rid = roundId; Result storage res = results[rid]; _record(res); _voidTail(res, rid, seedBlock); } function _voidTail(Result storage res, uint64 rid, uint64 sb) private { res.rolledOver = true; res.voided = true; emit RoundVoided(rid, sb); _openNext(rid); } /// Pull creator fees from the Pons escrow into this contract. Anyone can call. function sweep() external nonReentrant { if (escrow == address(0)) revert NothingToDo(); uint256 due = prizeToken == address(0) ? IPonsV2FeeEscrow(escrow).balanceOf(address(this)) : IPonsV2FeeEscrow(escrow).balanceOfToken(address(this), prizeToken); if (due == 0) revert NothingToDo(); uint256 before = _held(); if (prizeToken == address(0)) IPonsV2FeeEscrow(escrow).claim(); else IPonsV2FeeEscrow(escrow).claimToken(prizeToken); emit Swept(_held() - before); } /// Pull a payout that could not be pushed. function claim() external nonReentrant { uint256 amount = claimable[msg.sender]; if (amount == 0) revert NothingToClaim(); claimable[msg.sender] = 0; claimableTotal -= amount; if (!_transfer(msg.sender, amount, gasleft())) revert PayoutFailed(); emit Claimed(msg.sender, amount); } /// A credit nobody has claimed for RECYCLE_AFTER goes back into the pot. Anyone can call. function recycle(address account) external { uint256 amount = claimable[account]; if (amount == 0 || block.timestamp < uint256(creditedAt[account]) + RECYCLE_AFTER) revert NotRecyclable(); claimable[account] = 0; claimableTotal -= amount; emit Recycled(account, amount); } // ------------------------------------------------------------------ views /// Prize asset not owed to anyone: the prize of the round being drawn plus whatever arrived since. function pot() public view returns (uint256) { return _held() - claimableTotal; } /// Seconds between the starts of two checkpoint slots. function checkpointSpacing() internal view returns (uint256) { return windowLength / (CHECKPOINTS - 1); } /// How long a checkpoint slot stays open: half the spacing, so consecutive checkpoints are always at least /// that far apart and the last one lands no later than closeOffset - checkpointGrace() before the hour. function checkpointGrace() internal view returns (uint256) { return checkpointSpacing() / 2; } /// When the next checkpoint's slot opens. function checkpointDue() public view returns (uint256) { uint8 c = checkpointsDone; if (c >= CHECKPOINTS) return 0; return roundEndsAt - closeOffset - uint256(CHECKPOINTS - 1 - c) * checkpointSpacing(); } /// When the next checkpoint's slot closes; after it, the round moves to the next hour. function checkpointDeadline() external view returns (uint256) { uint256 due = checkpointDue(); return due == 0 ? 0 : due + checkpointGrace(); } /// The top of the hour the open round is actually headed for (roundEndsAt, or the next hour once a /// checkpoint slot has been missed). function scheduledRoundEnd() external view returns (uint256) { if (locked && phase == Phase.Open && block.timestamp > checkpointDue() + checkpointGrace()) return _nextRoundEnd(); return roundEndsAt; } function checkpointInfo(uint8 c) external view returns (uint32 wallets, uint64 blockNumber, uint64 timestamp) { return (cpCount[c], cpBlock[c], cpTime[c]); } function totalTickets() external view returns (uint256) { return snapshotTotal; } function snapshotCount() external view returns (uint32) { return cpCount[CHECKPOINTS - 1]; } function eligibleTicketsOf(address holder) external view returns (uint64) { uint32 idx = indexOf[holder]; return idx == 0 ? 0 : slots[idx].tickets; } function isSittingOut(address holder) external view returns (bool) { uint32 idx = indexOf[holder]; return idx != 0 && slots[idx].satOut; } function slotOf(uint32 idx) external view returns (Slot memory) { return slots[idx]; } function getResult(uint64 id) external view returns (Result memory) { return results[id]; } // ------------------------------------------------------------------ internals /// Close round `rid` and open the next one for the next hour whose first checkpoint is still ahead. function _openNext(uint64 rid) private { roundId = rid + 1; phase = Phase.Open; checkpointsDone = 0; roundPrize = 0; roundEndsAt = _nextRoundEnd(); lastCompletedAt = uint64(block.timestamp); missedStreak = 0; evictedThisRound = 0; } /// Swap-delete a registered wallet (shared by prune, replace-cleanup and the freeze escape). function _remove(uint32 idx, address wallet) private { uint32 last = holderCount; if (idx != last) { address moved = holderAt[last]; holderAt[idx] = moved; indexOf[moved] = idx; slots[idx] = slots[last]; } delete holderAt[last]; delete slots[last]; delete indexOf[wallet]; holderCount = last - 1; } function _derive(bytes32 seed, uint64 rid, uint8 tag, uint256 j) private view returns (uint256) { return uint256(keccak256(abi.encode(seed, address(this), rid, tag, j))); } /// The first top of the hour whose first checkpoint is still ahead. function _nextRoundEnd() private view returns (uint64) { return uint64(((block.timestamp + closeOffset + windowLength) / ROUND_LENGTH + 1) * ROUND_LENGTH); } function _admissible(address w) private view returns (bool) { return w != address(0) && indexOf[w] == 0 && !excluded[w] && !_isContract(w); } /// Largest index whose cumulative range starts at or before r. Binary search over this round's tickets; /// with r below the total it always lands on a wallet holding tickets. function _find(uint256 r) private view returns (uint32) { uint32 lo = 1; uint32 hi = cpCount[CHECKPOINTS - 1]; while (lo < hi) { uint32 mid = uint32((uint256(lo) + uint256(hi) + 1) / 2); if (slots[mid].prefixBefore <= r) lo = mid; else hi = mid - 1; } return lo; } function _held() private view returns (uint256) { return prizeToken == address(0) ? address(this).balance : IERC20Min(prizeToken).balanceOf(address(this)); } function _pay(address to, uint256 amount) private { if (amount == 0) return; if (!_transfer(to, amount, PAY_GAS)) { claimable[to] += amount; claimableTotal += amount; creditedAt[to] = uint64(block.timestamp); emit PayoutCredited(to, amount); } } /// Native ETH with a gas stipend, or an ERC-20 transfer whose failure (revert, false, blocklist) is reported, never thrown. function _transfer(address to, uint256 amount, uint256 gas) private returns (bool ok) { if (prizeToken == address(0)) { (ok, ) = to.call{value: amount, gas: gas}(""); } else { bytes memory data; (ok, data) = prizeToken.call{gas: gas > TOKEN_PAY_GAS ? gas : TOKEN_PAY_GAS}(abi.encodeWithSelector(IERC20Min.transfer.selector, to, amount)); ok = ok && (data.length == 0 || (data.length == 32 && abi.decode(data, (bool)))); } } /// Contracts never hold tickets. An EIP-7702 delegated wallet (code 0xef0100‖address) still counts as a wallet. function _isContract(address a) private view returns (bool) { uint256 size; assembly { size := extcodesize(a) } if (size == 0) return false; if (size == 23) { bytes memory head = new bytes(3); assembly { extcodecopy(a, add(head, 32), 0, 3) } if (head[0] == 0xef && head[1] == 0x01 && head[2] == 0x00) return false; } return true; } }