WifReflections

0x6466d378375826be149994be0562d40f77cca173

Verification
Verified
v0.8.20+commit.a1b79de6
Type
Contract
6,968 bytes
ABI entries
47
19 read · 12 write
License
none

Contract information

Address
0x6466d378375826be149994be0562d40f77cca173
Chain
Robinhood Chain (4663)
Compiler
v0.8.20+commit.a1b79de6
Optimization
Enabled
Creation tx
0x5b83ad5cca…20d72af6b6

Token

Not a token

This contract does not expose ERC-20 metadata.

Read contract (19)

budgetRemaining()uint256
currentEpoch()uint256
eligibleCount(uint256)uint256
emissionPerEpoch()uint256
epochInterval()uint256
epochsRemaining()uint256
excluded(address)bool
keeper()address
lastEpochStart()uint256
minHolding()uint256
nextEpochIn()uint256
owner()address
paid(uint256, address)bool
pendingFor(address)uint256
phase()uint8
snapshotBalance(uint256, address)uint256
token()address
totalEligible(uint256)uint256
totalPaid(uint256)uint256

Events (9)

ConfigUpdatedDistributedEpochFinalizedEpochStartedExcludedSetKeeperUpdatedOwnershipTransferredSnapshotSealedSnapshotted

ABI

[
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token_",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "epochInterval_",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "emissionPerEpoch_",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "minHolding_",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "currentEpoch_",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "lastEpochStart_",
        "type": "uint256"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "constructor"
  },
  {
    "inputs": [],
    "name": "EmptySnapshot",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotKeeper",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "TooSoon",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "WrongPhase",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "ZeroConfig",
    "type": "error"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "epochInterval",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "emissionPerEpoch",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "minHolding",
        "type": "uint256"
      }
    ],
    "name": "ConfigUpdated",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "epoch",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "holdersPaid",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amountPaid",
        "type": "uint256"
      }
    ],
    "name": "Distributed",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "epoch",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "totalPaid",
        "type": "uint256"
      }
    ],
    "name": "EpochFinalized",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "epoch",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "emission",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "minHolding",
        "type": "uint256"
      }
    ],
    "name": "EpochStarted",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "account",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "bool",
        "name": "isExcluded",
        "type": "bool"
      }
    ],
    "name": "ExcludedSet",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": false,
        "internalType": "address",
        "name": "keeper",
        "type": "address"
      }
    ],
    "name": "KeeperUpdated",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "previousOwner",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "newOwner",
        "type": "address"
      }
    ],
    "name": "OwnershipTransferred",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "epoch",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "totalEligible",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "eligibleCount",
        "type": "uint256"
      }
    ],
    "name": "SnapshotSealed",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "epoch",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "added",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "totalEligible",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "eligibleCount",
        "type": "uint256"
      }
    ],
    "name": "Snapshotted",
    "type": "event"
  },
  {
    "inputs": [],
    "name": "budgetRemaining",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "currentEpoch",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address[]",
        "name": "holders",
        "type": "address[]"
      }
    ],
    "name": "distribute",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "name": "eligibleCount",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "emissionPerEpoch",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "epochInterval",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "epochsRemaining",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "name": "excluded",
    "outputs": [
      {
        "internalType": "bool",
        "name": "",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "finalizeEpoch",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "keeper",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "lastEpochStart",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "minHolding",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "nextEpochIn",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "owner",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      },
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "name": "paid",
    "outputs": [
      {
        "internalType": "bool",
        "name": "",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "holder",
        "type": "address"
      }
    ],
    "name": "pendingFor",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "phase",
    "outputs": [
      {
        "internalType": "enum WifReflections.Phase",
        "name": "",
        "type": "uint8"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "renounceOwnership",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "sealSnapshot",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "epochInterval_",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "emissionPerEpoch_",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "minHolding_",
        "type": "uint256"
      }
    ],
    "name": "setConfig",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "account",
        "type": "address"
      },
      {
        "internalType": "bool",
        "name": "isExcluded",
        "type": "bool"
      }
    ],
    "name": "setExcluded",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "keeper_",
        "type": "address"
      }
    ],
    "name": "setKeeper",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address[]",
        "name": "holders",
        "type": "address[]"
      }
    ],
    "name": "snapshot",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      },
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "name": "snapshotBalance",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "startEpoch",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "token",
    "outputs": [
      {
        "internalType": "contract IERC20",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "name": "totalEligible",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "name": "totalPaid",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "newOwner",
        "type": "address"
      }
    ],
    "name": "transferOwnership",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      },
      {
        "internalType": "address",
        "name": "to",
        "type": "address"
      }
    ],
    "name": "withdrawEth",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token_",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      },
      {
        "internalType": "address",
        "name": "to",
        "type": "address"
      }
    ],
    "name": "withdrawToken",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "stateMutability": "payable",
    "type": "receive"
  }
]

Source code

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol";

/**
 * @title WifReflections
 * @notice Timed, pro-rata token reflections for holders of a standard
 * (tax-free) ERC20 — built for Sentry-launched tokens, whose vanilla
 * token contract can't do fee-on-transfer reflections by design.
 *
 * V2 of SentryReflections (0xd51b038E...54EE0), same mechanics with two
 * changes: (1) EIP-7702-delegated EOAs (code = 23-byte 0xef0100||addr
 * designator) count as normal wallets — Robinhood-app users delegate
 * their EOAs and were silently skipped by V1's code check; real
 * contracts (pools, lockers, Safes) remain excluded. (2) The
 * constructor seeds the epoch counter and schedule so a migration
 * continues the V1 cadence instead of resetting it.
 *
 * The contract is funded with the reward budget (e.g. 20M WIF) and
 * pays it out over fixed epochs. Each epoch:
 *
 *   1. startEpoch()            — anyone may ring the bell once the
 *                                interval has elapsed; opens a snapshot.
 *   2. snapshot(holders[])     — keeper submits candidate holders in
 *                                batches. THE CONTRACT reads each
 *                                balanceOf() itself, applies the
 *                                minimum-holding bar and the exclusion
 *                                list, and records the eligible balance.
 *                                The keeper can therefore omit someone
 *                                (visible on-chain) but can never
 *                                inflate a balance, double-count a
 *                                wallet, or invent a holder.
 *   3. sealSnapshot()          — locks the eligible set + total.
 *   4. distribute(holders[])   — batched pro-rata payout:
 *                                amount = emission * snapBal / totalEligible.
 *                                Contract-enforced: a holder is paid at
 *                                most once, the epoch never pays more
 *                                than emissionPerEpoch, and payouts only
 *                                come from the funded budget.
 *
 * Off-chain, a keeper (Sentry's cron) sources the holder list from the
 * chain explorer / subgraph and submits it — the math and all spending
 * limits live here. Exclusions (the Uniswap pool, the LP locker, dead
 * addresses, the distributor itself) are owner-maintained and public.
 *
 * The minimum holding is denominated in TOKENS. A USD bar ("$100")
 * moves with price, so the owner/keeper periodically re-derives the
 * token amount from the pool price and calls setMinHolding — on-chain
 * USD oracles don't exist on this chain yet.
 *
 * ETH sent to the contract is held for future use (ETH reflections or
 * keeper-gas top-ups) and is owner-withdrawable; nothing here requires
 * the contract to hold ETH.
 */
contract WifReflections is Ownable, ReentrancyGuard {
    using SafeERC20 for IERC20;

    IERC20 public immutable token;

    /// @notice Seconds between epochs (e.g. 7 days).
    uint256 public epochInterval;
    /// @notice Reward tokens paid out per epoch, split pro-rata.
    uint256 public emissionPerEpoch;
    /// @notice Minimum token balance to be eligible (the "$100 bar").
    uint256 public minHolding;
    /// @notice Addresses that never receive reflections (pool, locker, ...).
    mapping(address => bool) public excluded;
    /// @notice Optional dedicated keeper; owner always allowed.
    address public keeper;

    enum Phase {
        Idle,          // between epochs
        Snapshotting,  // startEpoch called, balances being recorded
        Distributing   // snapshot sealed, payouts in progress
    }

    uint256 public currentEpoch;      // 0 = none yet
    uint256 public lastEpochStart;    // timestamp startEpoch last succeeded
    Phase public phase;

    // Per-epoch snapshot state. Keyed by epoch id so stale entries from
    // prior epochs can never leak into the current one.
    mapping(uint256 => mapping(address => uint256)) public snapshotBalance;
    mapping(uint256 => mapping(address => bool)) public paid;
    mapping(uint256 => uint256) public totalEligible;
    mapping(uint256 => uint256) public eligibleCount;
    mapping(uint256 => uint256) public totalPaid;

    event EpochStarted(uint256 indexed epoch, uint256 emission, uint256 minHolding);
    event Snapshotted(uint256 indexed epoch, uint256 added, uint256 totalEligible, uint256 eligibleCount);
    event SnapshotSealed(uint256 indexed epoch, uint256 totalEligible, uint256 eligibleCount);
    event Distributed(uint256 indexed epoch, uint256 holdersPaid, uint256 amountPaid);
    event EpochFinalized(uint256 indexed epoch, uint256 totalPaid);
    event ExcludedSet(address indexed account, bool isExcluded);
    event ConfigUpdated(uint256 epochInterval, uint256 emissionPerEpoch, uint256 minHolding);
    event KeeperUpdated(address keeper);

    error NotKeeper();
    error WrongPhase();
    error TooSoon();
    error EmptySnapshot();
    error ZeroConfig();

    modifier onlyKeeper() {
        if (msg.sender != owner() && msg.sender != keeper) revert NotKeeper();
        _;
    }

    constructor(
        address token_,
        uint256 epochInterval_,
        uint256 emissionPerEpoch_,
        uint256 minHolding_,
        uint256 currentEpoch_,
        uint256 lastEpochStart_
    ) {
        if (token_ == address(0) || epochInterval_ == 0 || emissionPerEpoch_ == 0) revert ZeroConfig();
        token = IERC20(token_);
        epochInterval = epochInterval_;
        emissionPerEpoch = emissionPerEpoch_;
        minHolding = minHolding_;
        // Migration seeds: continue a predecessor's epoch numbering and
        // schedule (0, 0 for a fresh start).
        currentEpoch = currentEpoch_;
        lastEpochStart = lastEpochStart_;
        // The distributor must never count its own reward pool as a
        // holder balance.
        excluded[address(this)] = true;
        excluded[address(0)] = true;
        excluded[0x000000000000000000000000000000000000dEaD] = true;
    }

    /* ── Epoch lifecycle ─────────────────────────────────────────────── */

    /// @notice Open the next epoch. Callable by anyone (the schedule is
    /// the guard, not the caller), but snapshotting stays keeper-only.
    function startEpoch() external {
        if (phase != Phase.Idle) revert WrongPhase();
        if (block.timestamp < lastEpochStart + epochInterval) revert TooSoon();
        // Refuse to open an epoch the budget can't cover — leftover dust
        // below one full emission stays withdrawable by the owner.
        require(token.balanceOf(address(this)) >= emissionPerEpoch, "budget exhausted");
        currentEpoch += 1;
        lastEpochStart = block.timestamp;
        phase = Phase.Snapshotting;
        emit EpochStarted(currentEpoch, emissionPerEpoch, minHolding);
    }

    /// @notice Record eligible balances for a batch of candidate
    /// holders. Balances are read on-chain HERE — the keeper only
    /// supplies addresses. Ineligible/duplicate/excluded entries are
    /// skipped, not reverted, so one bad address can't wedge a batch.
    function snapshot(address[] calldata holders) external onlyKeeper {
        if (phase != Phase.Snapshotting) revert WrongPhase();
        uint256 epoch = currentEpoch;
        uint256 added;
        uint256 addedBalance;
        for (uint256 i = 0; i < holders.length; i++) {
            address holder = holders[i];
            if (excluded[holder]) continue;
            if (snapshotBalance[epoch][holder] != 0) continue; // already counted
            // EOAs only: pools, lockers, routers, and other contracts
            // never earn reflections. An EIP-7702-delegated EOA carries
            // the 23-byte designator 0xef0100||delegate — that is still
            // a person's wallet (Robinhood-app users delegate), so it
            // stays eligible.
            if (!_isEoa(holder)) continue;
            uint256 bal = token.balanceOf(holder);
            if (bal < minHolding) continue;
            snapshotBalance[epoch][holder] = bal;
            addedBalance += bal;
            added += 1;
        }
        totalEligible[epoch] += addedBalance;
        eligibleCount[epoch] += added;
        emit Snapshotted(epoch, added, totalEligible[epoch], eligibleCount[epoch]);
    }

    /// @notice Lock the snapshot and open distribution.
    function sealSnapshot() external onlyKeeper {
        if (phase != Phase.Snapshotting) revert WrongPhase();
        if (totalEligible[currentEpoch] == 0) revert EmptySnapshot();
        phase = Phase.Distributing;
        emit SnapshotSealed(currentEpoch, totalEligible[currentEpoch], eligibleCount[currentEpoch]);
    }

    /// @notice Pay a batch of snapshotted holders their pro-rata share:
    /// emission * snapshotBalance / totalEligible. Each holder pays out
    /// at most once per epoch; un-snapshotted addresses are skipped.
    function distribute(address[] calldata holders) external onlyKeeper nonReentrant {
        if (phase != Phase.Distributing) revert WrongPhase();
        uint256 epoch = currentEpoch;
        uint256 tot = totalEligible[epoch];
        uint256 emission = emissionPerEpoch;
        uint256 paidNow;
        uint256 count;
        for (uint256 i = 0; i < holders.length; i++) {
            address holder = holders[i];
            uint256 bal = snapshotBalance[epoch][holder];
            if (bal == 0 || paid[epoch][holder]) continue;
            paid[epoch][holder] = true;
            uint256 amount = (emission * bal) / tot;
            if (amount == 0) continue;
            paidNow += amount;
            count += 1;
            token.safeTransfer(holder, amount);
        }
        totalPaid[epoch] += paidNow;
        // Pro-rata shares of a fixed emission can never sum past the
        // emission; this assert documents the invariant distribution
        // relies on (rounding dust stays in the pool for later epochs).
        assert(totalPaid[epoch] <= emission);
        emit Distributed(epoch, count, paidNow);
    }

    /// @notice Close the epoch. Distribution doesn't need to have
    /// reached every snapshotted holder (a wallet gone to zero pays
    /// zero anyway); whatever wasn't sent stays in the budget.
    function finalizeEpoch() external onlyKeeper {
        if (phase != Phase.Distributing) revert WrongPhase();
        phase = Phase.Idle;
        emit EpochFinalized(currentEpoch, totalPaid[currentEpoch]);
    }

    /// @dev True for plain EOAs and for EIP-7702-delegated EOAs (whose
    /// code is exactly the 23-byte designator 0xef0100 || delegate).
    function _isEoa(address account) private view returns (bool) {
        bytes memory code = account.code;
        if (code.length == 0) return true;
        return code.length == 23 && code[0] == 0xef && code[1] == 0x01 && code[2] == 0x00;
    }

    /* ── Views ───────────────────────────────────────────────────────── */

    /// @notice Seconds until the next epoch can start (0 = ready).
    function nextEpochIn() external view returns (uint256) {
        uint256 nextAt = lastEpochStart + epochInterval;
        return block.timestamp >= nextAt ? 0 : nextAt - block.timestamp;
    }

    /// @notice Remaining reward budget in the pool.
    function budgetRemaining() external view returns (uint256) {
        return token.balanceOf(address(this));
    }

    /// @notice Full epochs the current budget can still fund.
    function epochsRemaining() external view returns (uint256) {
        return token.balanceOf(address(this)) / emissionPerEpoch;
    }

    /// @notice What `holder` would receive this epoch (0 before seal).
    function pendingFor(address holder) external view returns (uint256) {
        uint256 epoch = currentEpoch;
        if (phase != Phase.Distributing || paid[epoch][holder]) return 0;
        uint256 tot = totalEligible[epoch];
        if (tot == 0) return 0;
        return (emissionPerEpoch * snapshotBalance[epoch][holder]) / tot;
    }

    /* ── Admin ───────────────────────────────────────────────────────── */

    function setExcluded(address account, bool isExcluded) external onlyOwner {
        excluded[account] = isExcluded;
        emit ExcludedSet(account, isExcluded);
    }

    /// @notice Update schedule/economics. Only between epochs so a
    /// mid-epoch change can't skew a snapshot already in progress.
    function setConfig(
        uint256 epochInterval_,
        uint256 emissionPerEpoch_,
        uint256 minHolding_
    ) external onlyOwner {
        if (phase != Phase.Idle) revert WrongPhase();
        if (epochInterval_ == 0 || emissionPerEpoch_ == 0) revert ZeroConfig();
        epochInterval = epochInterval_;
        emissionPerEpoch = emissionPerEpoch_;
        minHolding = minHolding_;
        emit ConfigUpdated(epochInterval_, emissionPerEpoch_, minHolding_);
    }

    function setKeeper(address keeper_) external onlyOwner {
        keeper = keeper_;
        emit KeeperUpdated(keeper_);
    }

    /// @notice Owner exit: pull unused budget (or any stray token) out.
    /// Reflections are a grant from the funder, not user deposits, so a
    /// withdrawal path is appropriate here (unlike the LP locker).
    function withdrawToken(address token_, uint256 amount, address to) external onlyOwner {
        IERC20(token_).safeTransfer(to, amount);
    }

    function withdrawEth(uint256 amount, address to) external onlyOwner {
        (bool ok,) = to.call{value: amount}("");
        require(ok, "eth send failed");
    }

    receive() external payable {}
}
Chain explorer1323msChain node81ms