Robinhood Chain · 4663

Contracts

The live HeatGPU contracts on Robinhood Chain. The reward token is read from HashMine. Every transaction is signed in your wallet.

Robinhood Chain deployment

Pass, staking and mining pool are live. The HGPU reward token is not set on HashMine yet, so mining is paused until the owner sets it.

This page never handles your private key; every write is confirmed in your wallet.

Every round with at least one accepted share pays at least 20,000 HGPU, plus more for the total compute miners submit in it, up to 0.5% of the unreserved pool (default), and miners split it pro rata. Buybacks from trading fees and NFT revenue are plain HGPU transfers into HashMine that refill the pool, so rewards never end while it holds tokens. The owner can retune the minimum round reward, reward per work, pool cap and pass multipliers within hard bounds behind a 30-minute timelock (changes apply from the next round and never to a round that already has work), and can move unowed pool funds to a successor contract behind the withdraw delay (all shown above while pending). Rewards already owed to miners — reserved pots, ended rounds and the open round — can never be withdrawn. Contract logic is not upgradeable: a logic change is a new contract funded through that withdraw.

Network
Robinhood Chain
Chain ID
4663
RPC
https://rpc.mainnet.chain.robinhood.com
Explorer
https://robinhoodchain.blockscout.com
Reward token
Not set yet (owner calls setToken once)

Contract parameters

Reward token
1B HGPU · 18 decimals
Initial pool allocation
150M HGPU (15%)
Round pot
min(20,000 + round work × 1.25e-7 HGPU, 0.5% of pool)
Owner changes
Bounded, 30-minute timelock, from the next round
Pool refills
Trading-fee and NFT-revenue buybacks
Pass collection
HeatGPU Pass ERC-721 (OpenSea drop)
Withdrawal cooldown
1 hour
Deployment network
Robinhood Chain · 4663
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

interface IERC20 {
    function transfer(address to, uint256 amount) external returns (bool);
    function balanceOf(address account) external view returns (uint256);
}

interface IRigStaking {
    function activeBalanceOf(address staker) external view returns (uint256);
}

/// @title HashMine — staked-NFT-gated share pool (v3.3: per-share target
///        difficulty, 512-share batches, 1 work per hash, no delegates;
///        v3.3.1: two-step ownership, O(1) settle-backlog accounting;
///        v3.3.2: share targets up to 2^56)
/// @notice Browser miners submit keccak shares. The contract recomputes every
///         hash. Work is scaled by actively staked HeatGPU Passes. Zero active
///         stake => zero work; withdrawal requests remove stake immediately.
///
///         Round pot (fixed once, at the first settle after the round ends):
///
///             pot = min(minRoundReward + roundWork * rewardPerWork,
///                       poolCapBps / 10_000 * (balance - reserved))
///
///         - Every round with at least one accepted share pays at least
///           minRoundReward (default 20,000 HGPU), then grows with the round's
///           total compute, never above poolCapBps (default 0.5%) of the pool
///           tokens that are not already owed.
///         - Rounds with no work pay nothing; their tokens stay in the pool.
///         - Miners split the pot pro rata to their weighted work. The last
///           miner to settle a round receives the exact remainder (no dust).
///         - There is no end round: buybacks are plain ERC-20 transfers into
///           this contract and raise later pots.
///
///         Shares (v3.3). Every nonce commits to its own target difficulty t
///         in its top byte: t = max(nonce >> 56, MIN_DIFFICULTY). A share is
///         valid iff t <= MAX_COUNTED_DIFFICULTY (56 since v3.3.2) and its hash has at least
///         t leading zero bits; it is credited exactly 2^t (times the pass
///         multiplier), whatever difficulty the hash happens to reach. A share
///         at target t takes 2^t hashes on average, so expected credit is
///         exactly 1 work per hash at every target. Clients pick t from their
///         measured hashrate (about 12-25 shares per round since v3.3.2), so even
///         a GPU cluster's whole round is one small transaction while slow
///         CPUs still use t=16.
///         (Crediting 2^(actual difficulty) instead would pay (cap - t)/2 + 1
///         work per hash -- more for low targets -- and its variance would be
///         dominated by rare 2^cap jackpots.)
///
///         Submissions (v3.2+). A batch no longer reverts because of one bad
///         share: nonces that were already accepted this round (including a
///         repeat inside the same batch) or that miss MIN_DIFFICULTY against
///         this round's challenge are skipped and counted in SharesSkipped.
///         A batch reverts only if no nonce in it is valid. checkShares() lets
///         clients pre-validate a batch with one eth_call.
///
///         Owner parameters (v3.2). rewardPerWork, minRoundReward, poolCapBps,
///         passMultiplierBps, maxMultiplierBps and withdrawDelay are adjustable
///         within hard bounds: proposeParam(id, value) -> wait PARAM_DELAY
///         (30 min, visible via pendingParam()) -> executeParam(id), or
///         cancelParam(id). Round parameters executed in round N apply from
///         round N + 1; every round snapshots all of them at its first share,
///         so a round that already has work is never affected. The withdraw
///         delay can only move within [30 min, 7 days] and applies to
///         withdraw proposals made after it is executed.
///
///         What is NOT adjustable, on purpose: ROUND_LENGTH (round indexing,
///         challenges and replay protection are all keyed on
///         (block.timestamp - genesis) / ROUND_LENGTH, so changing it would
///         re-number open and closed rounds), MIN_DIFFICULTY and
///         MAX_COUNTED_DIFFICULTY (miners and clients hash against them; the
///         economics they influence are already tunable via rewardPerWork),
///         MAX_BATCH, and the contract logic itself. There is no proxy: a logic change is
///         a new contract, funded by the timelocked migration withdraw below.
///
///         Migration withdraw. The owner can move UNOWED pool tokens to a
///         successor: proposeWithdraw(to, amount) -> wait withdrawDelay ->
///         executeWithdraw(). executeWithdraw first settles every ended round
///         with work (in order, against the pre-withdraw balance), then requires
///         amount <= balance - reserved - openRoundHoldback, where the holdback
///         keeps the open round able to pay minRoundReward + work * rate in
///         full. Owed rewards can never be withdrawn. rescueToken() returns other
///         ERC-20s sent here by mistake; it can never move the reward token.
///
///         v3.3.1 (mainnet build; same share protocol and economics as v3.3):
///         - Ownership is two-step: transferOwnership(newOwner) only nominates,
///           newOwner must call acceptOwnership(). renounceOwnership() is explicit.
///         - settleBacklog / settleBacklogLength / maxWithdrawable no longer walk
///           every unsettled work round to count them (the work-round list is
///           sorted and only its last entry can be the open round), so a long
///           unsettled history can no longer push executeWithdraw past the
///           block gas limit.
///         - Reward-token transfers accept ERC-20s that return no value.
///         - settle() emits Settled(round, miner, work, amount).
///
///         v3.3.2: MAX_COUNTED_DIFFICULTY 40 -> 56 so clients can aim for
///         ~12-25 shares per round at any hashrate (gas stays low). Same share
///         encoding, crediting (exactly 2^t, 1 work per hash in expectation),
///         economics and storage layout as v3.3.1. The multiplier cap
///         default is now 2,222.00x (DEFAULT_MAX_MULTIPLIER_BPS 22,220,000) so
///         every pass of the 2,222 supply adds its full 1.00x; the owner bound
///         is 2,500x. The multiplier is O(1): RigStaking keeps a per-staker
///         counter (activeBalanceOf), never a loop over token ids.
///
///         v3.4.0: the reward token is no longer a constructor argument, so
///         RigStaking and HashMine can be deployed before HGPU exists. The
///         owner calls setToken(token) exactly once; it can never be changed
///         afterwards. Until it is set: submit() and executeWithdraw() revert
///         ("HashMine: token not set"), availablePool() is 0, no round can get
///         work (so no pot is ever fixed against an empty pool), claim() has
///         nothing to pay, rescueToken() reverts (it cannot move the future
///         reward token) and renounceOwnership() reverts (the owner must set
///         the token first). Share protocol, economics and owner timelocks are
///         unchanged from v3.3.2.
contract HashMine {
    /// @notice Round parameters. Packed into one storage slot.
    struct Params {
        uint64 rewardPerWork; // HGPU wei per unit of weighted work
        uint96 minRoundReward; // HGPU wei paid to any round with work (before the cap)
        uint16 poolCapBps; // max pot as bps of the unreserved pool
        uint32 passMultiplierBps; // work multiplier added per actively staked pass
        uint32 maxMultiplierBps; // multiplier cap
    }

    struct RoundInfo {
        uint128 work; // total weighted work submitted in the round
        bool settled; // pot fixed (possibly 0)
        bool snapshotted; // params copied at the round's first share
        Params params; // snapshot
    }

    struct RoundPayout {
        uint128 potLeft; // pot not yet credited to miners
        uint128 workLeft; // work not yet settled
    }

    struct PendingParam {
        uint256 value;
        uint64 eta; // 0 => none pending
    }

    struct PendingWithdraw {
        address to;
        uint96 unlockTime; // 0 => no pending withdraw
        uint256 amount;
    }

    string public constant VERSION = "HashMine-v3.4.0";
    IRigStaking public immutable staking;

    uint256 public constant ROUND_LENGTH = 600;
    uint256 public constant BPS = 10_000;
    uint256 public constant MIN_DIFFICULTY = 16;
    /// @notice Highest target difficulty a share may commit to (credited 2^56).
    ///         56 = the nonce's free counter bits: a target-t share space holds
    ///         2^56 nonces, so above 56 a miner could not even expect one share
    ///         per target byte. 2^56 hashes ~ 7.2e16; at 25-50 shares per round
    ///         that is ~3-6e15 H/s per wallet, beyond any GPU cluster. Max work
    ///         per share 2^56 * 2,500x (MAX_MAX_MULTIPLIER_BPS) ~ 1.8e20, per
    ///         512-share batch ~ 9.2e22 (< 2^77): far inside the uint128 round
    ///         work, and uint128 work * rewardPerWork (< 2^41) fits uint256.
    uint256 public constant MAX_COUNTED_DIFFICULTY = 56;
    /// @notice Expected credited base work per hash at any target: P(lz >= t) * 2^t = 1.
    uint256 public constant EXPECTED_BASE_WORK_PER_HASH = 1;
    /// @notice Safety ceiling per submit (clients aim for ~12-25 shares per round).
    ///         ~29k gas per share: 256 shares ~7.5M gas, 512 ~14.8M, well under
    ///         Arbitrum's 32M per-transaction gas limit.
    uint256 public constant MAX_BATCH = 512;
    uint256 public constant MAX_CHECK_BATCH = 512;
    uint256 public constant MAX_SETTLE_ROUNDS_PER_CLAIM = 16;
    /// @notice Ended rounds executeWithdraw settles itself; a longer backlog
    ///         must be cleared with settleBacklog() first.
    uint256 public constant MAX_SETTLE_PER_WITHDRAW = 64;

    /// @notice Timelock for every parameter change (30 min, equal to the default withdraw delay).
    uint256 public constant PARAM_DELAY = 30 minutes;

    // Parameter ids for proposeParam / executeParam / cancelParam / pendingParam.
    uint8 public constant PARAM_REWARD_PER_WORK = 0;
    uint8 public constant PARAM_MIN_ROUND_REWARD = 1;
    uint8 public constant PARAM_POOL_CAP_BPS = 2;
    uint8 public constant PARAM_PASS_MULTIPLIER_BPS = 3;
    uint8 public constant PARAM_MAX_MULTIPLIER_BPS = 4;
    uint8 public constant PARAM_WITHDRAW_DELAY = 5;
    uint8 public constant PARAM_COUNT = 6;

    // rewardPerWork calibration (1 work per hash): 10 GH/s of network hashrate
    // at 1.00x hashes 6e12 per 600 s round, which alone reaches the 0.5% cap of
    // a full 150M pool: 750_000e18 / 6e12 = 1.25e11 wei per work. With the 20k
    // minimum on top, the cap binds from ~9.73 GH/s. An RTX 2060 (~0.7 GH/s,
    // 4.2e11 work) alone gets 20,000 + 52,500 = 72,500 HGPU.
    uint256 public constant DEFAULT_REWARD_PER_WORK = 1.25e11;
    uint256 public constant MIN_REWARD_PER_WORK = DEFAULT_REWARD_PER_WORK / 10;
    uint256 public constant MAX_REWARD_PER_WORK = DEFAULT_REWARD_PER_WORK * 10;
    uint256 public constant DEFAULT_MIN_ROUND_REWARD = 20_000 ether;
    uint256 public constant MAX_MIN_ROUND_REWARD = 1_000_000 ether;
    uint256 public constant DEFAULT_POOL_CAP_BPS = 50; // 0.5%
    uint256 public constant MIN_POOL_CAP_BPS = 1; // 0.01%
    uint256 public constant MAX_POOL_CAP_BPS = 500; // 5%
    uint256 public constant DEFAULT_PASS_MULTIPLIER_BPS = 10_000; // +1.00x per pass
    uint256 public constant MIN_PASS_MULTIPLIER_BPS = 1_000; // 0.10x
    uint256 public constant MAX_PASS_MULTIPLIER_BPS = 50_000; // 5.00x
    /// @dev v3.3.2: 1.00x per pass for every pass of the 2,222 supply.
    uint256 public constant DEFAULT_MAX_MULTIPLIER_BPS = 22_220_000; // 2,222.00x
    uint256 public constant MIN_MAX_MULTIPLIER_BPS = 10_000; // 1.00x
    uint256 public constant MAX_MAX_MULTIPLIER_BPS = 25_000_000; // 2,500.00x (fits Params.uint32)
    uint256 public constant DEFAULT_WITHDRAW_DELAY = 30 minutes;
    uint256 public constant MIN_WITHDRAW_DELAY = 30 minutes;
    uint256 public constant MAX_WITHDRAW_DELAY = 7 days;

    uint64 public immutable genesis;
    address public owner;

    /// @notice Delay between proposeWithdraw and the earliest executeWithdraw.
    uint256 public withdrawDelay = DEFAULT_WITHDRAW_DELAY;

    Params private _params; // rounds before nextParamsFromRound (or all rounds if 0)
    Params private _nextParams; // rounds >= nextParamsFromRound
    /// @notice First round that uses the executed-but-not-yet-active params (0 => none).
    uint256 public nextParamsFromRound;
    PendingParam[6] private _pendingParams;

    mapping(uint256 => mapping(address => uint256)) public workOf;
    mapping(uint256 => RoundInfo) private _rounds;
    mapping(uint256 => RoundPayout) private _payouts;
    mapping(uint256 => uint256) public roundPot;
    mapping(uint256 => mapping(bytes32 => bool)) public usedShare;
    mapping(address => uint256) public accrued;
    mapping(address => uint256[]) private _roundsMined;
    mapping(address => uint256) public claimIndex;
    /// @notice Tokens owed: unpaid pots of settled rounds plus unclaimed accruals.
    uint256 public reserved;

    /// @dev Every round that received work, in increasing order.
    uint256[] private _workRounds;
    /// @notice Index into the work-round list below which every round is settled.
    uint256 public settleCursor;

    PendingWithdraw private _pendingWithdraw;

    /// @notice Nominated owner; becomes owner by calling acceptOwnership().
    ///         (Declared last so the v3.3 storage layout is unchanged.)
    address public pendingOwner;

    /// @notice Reward token. address(0) until the owner calls setToken() once.
    IERC20 public rewardToken;

    /// @dev `difficulty` is the share's committed target t; `work` = 2^t * multiplier / BPS.
    event Share(address indexed miner, uint256 indexed round, uint64 nonce, uint8 difficulty, uint256 work);
    event SharesSkipped(address indexed miner, uint256 indexed round, uint256 skipped);
    event RoundSettled(uint256 indexed round, uint256 work, uint256 rewardPerWork, uint256 minRoundReward, uint256 pot);
    event Claimed(address indexed miner, uint256 amount);
    /// @dev `amount` is the miner's credited share of the round pot (may be 0).
    event Settled(uint256 indexed round, address indexed miner, uint256 work, uint256 amount);
    event ParamProposed(uint8 indexed id, uint256 value, uint256 eta);
    event ParamCancelled(uint8 indexed id, uint256 value);
    event ParamExecuted(uint8 indexed id, uint256 value, uint256 fromRound);
    event OwnershipTransferStarted(address indexed previousOwner, address indexed newOwner);
    event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
    event WithdrawProposed(address indexed to, uint256 amount, uint256 unlockTime);
    event WithdrawCancelled(address indexed to, uint256 amount);
    event WithdrawExecuted(address indexed to, uint256 amount);
    event TokenRescued(address indexed token, address indexed to, uint256 amount);
    event TokenSet(address indexed token);

    modifier onlyOwner() {
        require(msg.sender == owner, "HashMine: not owner");
        _;
    }

    modifier tokenIsSet() {
        require(address(rewardToken) != address(0), "HashMine: token not set");
        _;
    }

    constructor(address staking_, uint64 genesis_) {
        require(staking_.code.length > 0, "HashMine: staking not a contract");
        staking = IRigStaking(staking_);
        genesis = genesis_ == 0 ? uint64(block.timestamp) : genesis_;
        owner = msg.sender;
        _params = Params({
            rewardPerWork: uint64(DEFAULT_REWARD_PER_WORK),
            minRoundReward: uint96(DEFAULT_MIN_ROUND_REWARD),
            poolCapBps: uint16(DEFAULT_POOL_CAP_BPS),
            passMultiplierBps: uint32(DEFAULT_PASS_MULTIPLIER_BPS),
            maxMultiplierBps: uint32(DEFAULT_MAX_MULTIPLIER_BPS)
        });
        emit OwnershipTransferred(address(0), msg.sender);
    }

    // ---------------------------------------------------------------- views

    function currentRound() public view returns (uint256) {
        if (block.timestamp < genesis) return 0;
        return (block.timestamp - genesis) / ROUND_LENGTH;
    }

    function challenge(uint256 round) public view returns (bytes32) {
        return keccak256(abi.encodePacked(address(this), round));
    }

    /// @notice Parameters that apply to round `rnd`: its snapshot if it has
    ///         work, else the params scheduled for it.
    function paramsFor(uint256 rnd) public view returns (Params memory) {
        RoundInfo storage r = _rounds[rnd];
        if (r.snapshotted) return r.params;
        uint256 from = nextParamsFromRound;
        return from != 0 && rnd >= from ? _nextParams : _params;
    }

    /// @notice Parameters of the open round.
    function currentParams() external view returns (Params memory) {
        return paramsFor(currentRound());
    }

    /// @notice Executed params waiting for their first round (fromRound 0 => none).
    function scheduledParams() external view returns (Params memory params, uint256 fromRound) {
        return (_nextParams, nextParamsFromRound);
    }

    /// @notice A proposed, not yet executed change (eta 0 => none).
    function pendingParam(uint8 id) external view returns (uint256 value, uint256 eta) {
        require(id < PARAM_COUNT, "HashMine: param id");
        PendingParam storage p = _pendingParams[id];
        return (p.value, p.eta);
    }

    function rewardPerWorkFor(uint256 rnd) external view returns (uint256) {
        return paramsFor(rnd).rewardPerWork;
    }

    function minRoundRewardFor(uint256 rnd) external view returns (uint256) {
        return paramsFor(rnd).minRoundReward;
    }

    function poolCapBpsFor(uint256 rnd) external view returns (uint256) {
        return paramsFor(rnd).poolCapBps;
    }

    /// @return basis points for the open round. 0 passes => 0. Each staked
    ///         pass adds passMultiplierBps, capped at maxMultiplierBps.
    function multiplierBps(address account) public view returns (uint256) {
        return _multiplier(staking.activeBalanceOf(account), paramsFor(currentRound()));
    }

    function _multiplier(uint256 passes, Params memory p) private pure returns (uint256) {
        uint256 bps = passes * p.passMultiplierBps;
        return bps > p.maxMultiplierBps ? p.maxMultiplierBps : bps;
    }

    function roundWork(uint256 rnd) external view returns (uint256) {
        return _rounds[rnd].work;
    }

    function roundSettled(uint256 rnd) external view returns (bool) {
        return _rounds[rnd].settled;
    }

    /// @notice Pot not yet credited to miners and work not yet settled for `rnd`.
    function roundPayout(uint256 rnd) external view returns (uint256 potLeft, uint256 workLeft) {
        RoundPayout storage p = _payouts[rnd];
        return (p.potLeft, p.workLeft);
    }

    /// @notice Pool tokens not owed to anyone yet.
    function availablePool() public view returns (uint256) {
        if (address(rewardToken) == address(0)) return 0;
        uint256 bal = rewardToken.balanceOf(address(this));
        return bal > reserved ? bal - reserved : 0;
    }

    /// @notice Largest pot the open round could get if settled now.
    function potCap() public view returns (uint256) {
        return (availablePool() * paramsFor(currentRound()).poolCapBps) / BPS;
    }

    /// @notice Pot of `rnd`: final value once settled; for an open (or future)
    ///         round, what it would pay if settled now with its current work —
    ///         at least min(minRoundReward, cap) even before its first share,
    ///         since any accepted share earns the minimum. Ended rounds with no
    ///         work return 0.
    function estimatedRoundPot(uint256 rnd) external view returns (uint256) {
        RoundInfo storage r = _rounds[rnd];
        if (r.settled) return roundPot[rnd];
        uint256 work = r.work;
        if (work == 0 && rnd < currentRound()) return 0;
        Params memory p = paramsFor(rnd);
        return _min(p.minRoundReward + work * p.rewardPerWork, (availablePool() * p.poolCapBps) / BPS);
    }

    function leadingZeroBits(bytes32 h) public pure returns (uint8) {
        uint256 x = uint256(h);
        if (x == 0) return 255; // saturate; a zero hash is not reachable in practice
        uint8 bits = 0;
        while (x >> 248 == 0) {
            x <<= 8;
            bits += 8;
        }
        while (x >> 255 == 0) {
            x <<= 1;
            bits++;
        }
        return bits;
    }

    /// @notice Target difficulty a nonce commits to: max(top byte, MIN_DIFFICULTY).
    ///         Targets above MAX_COUNTED_DIFFICULTY are invalid.
    function shareTarget(uint64 nonce) public pure returns (uint8 t) {
        t = uint8(nonce >> 56);
        if (t < MIN_DIFFICULTY) t = uint8(MIN_DIFFICULTY);
    }

    /// @notice Pre-validate nonces for `miner` against the open round.
    /// @return status per nonce: 0 valid, 1 already accepted this round,
    ///         2 misses its target difficulty (or the target is above
    ///         MAX_COUNTED_DIFFICULTY), 3 repeats an earlier nonce in `nonces`.
    function checkShares(address miner, uint64[] calldata nonces) external view returns (uint8[] memory status) {
        uint256 n = nonces.length;
        require(n <= MAX_CHECK_BATCH, "HashMine: batch");
        uint256 rnd = currentRound();
        bytes32 ch = challenge(rnd);
        status = new uint8[](n);
        // Repeats are found with a small open-addressing set in memory (O(n)),
        // so a full MAX_CHECK_BATCH call stays a few million gas.
        uint256 size = 1;
        while (size < 2 * n) size <<= 1;
        uint256[] memory seen = new uint256[](size);
        for (uint256 i = 0; i < n; i++) {
            uint64 nonce = nonces[i];
            bool repeated;
            uint256 slot = uint256(keccak256(abi.encodePacked(nonce))) & (size - 1);
            while (true) {
                uint256 v = seen[slot];
                if (v == 0) {
                    seen[slot] = uint256(nonce) + 1;
                    break;
                }
                if (v == uint256(nonce) + 1) {
                    repeated = true;
                    break;
                }
                slot = (slot + 1) & (size - 1);
            }
            if (repeated) status[i] = 3;
            else if (usedShare[rnd][keccak256(abi.encodePacked(miner, rnd, nonce))]) status[i] = 1;
            else if (!_meetsTarget(miner, ch, nonce)) status[i] = 2;
        }
    }

    function roundsMinedCount(address miner) external view returns (uint256) {
        return _roundsMined[miner].length;
    }

    function roundsMined(address miner, uint256 start, uint256 count)
        external
        view
        returns (uint256[] memory rounds)
    {
        uint256[] storage all = _roundsMined[miner];
        if (start >= all.length) return new uint256[](0);
        uint256 end = start + count;
        if (end > all.length) end = all.length;
        rounds = new uint256[](end - start);
        for (uint256 i = start; i < end; i++) rounds[i - start] = all[i];
    }

    /// @return true when the miner has closed rounds that are not settled yet.
    function hasUnsettledRounds(address miner) external view returns (bool) {
        uint256 i = claimIndex[miner];
        uint256[] storage all = _roundsMined[miner];
        return i < all.length && all[i] < currentRound();
    }

    // --------------------------------------------------------------- mining

    /// @return accepted number of shares credited (invalid/duplicate nonces are skipped).
    function submit(uint64[] calldata nonces) external tokenIsSet returns (uint256 accepted) {
        return _submit(msg.sender, nonces);
    }

    function _submit(address miner, uint64[] calldata nonces) private returns (uint256 accepted) {
        uint256 n = nonces.length;
        require(n > 0 && n <= MAX_BATCH, "HashMine: batch");
        uint256 rnd = currentRound();
        RoundInfo storage r = _rounds[rnd];
        if (!r.snapshotted) {
            r.params = paramsFor(rnd);
            r.snapshotted = true; // reverted with the batch if nothing is accepted
        }
        uint256 m = _multiplier(staking.activeBalanceOf(miner), r.params);
        require(m > 0, "HashMine: need a HeatGPU Pass");

        uint256 added;
        (added, accepted) = _creditShares(miner, rnd, m, nonces);
        require(accepted > 0, "HashMine: no valid shares");
        if (accepted < n) emit SharesSkipped(miner, rnd, n - accepted);

        if (workOf[rnd][miner] == 0) _roundsMined[miner].push(rnd);
        workOf[rnd][miner] += added;

        if (r.work == 0) _workRounds.push(rnd);
        uint256 total = uint256(r.work) + added;
        require(total <= type(uint128).max, "HashMine: work overflow");
        r.work = uint128(total);
    }

    /// @dev Marks and credits every valid, not yet used nonce; skips the rest.
    function _creditShares(address miner, uint256 rnd, uint256 m, uint64[] calldata nonces)
        private
        returns (uint256 added, uint256 accepted)
    {
        bytes32 ch = challenge(rnd);
        mapping(bytes32 => bool) storage used = usedShare[rnd];
        for (uint256 i = 0; i < nonces.length; i++) {
            uint64 nonce = nonces[i];
            bytes32 id = keccak256(abi.encodePacked(miner, rnd, nonce));
            if (used[id]) continue; // already accepted (earlier tx or earlier in this batch)
            if (!_meetsTarget(miner, ch, nonce)) continue; // wrong round/address, misses its target
            used[id] = true;
            uint8 t = shareTarget(nonce);
            uint256 work = (uint256(1) << t) * m / BPS;
            added += work;
            accepted++;
            emit Share(miner, rnd, nonce, t, work);
        }
    }

    /// @dev Target in range and keccak(miner, ch, nonce) has >= target leading zero bits.
    ///      `virtual` only so the Foundry harness can credit targets up to the
    ///      cap without mining 2^56-hash shares; HashMine itself never overrides it.
    function _meetsTarget(address miner, bytes32 ch, uint64 nonce) internal pure virtual returns (bool) {
        uint8 t = shareTarget(nonce);
        if (t > MAX_COUNTED_DIFFICULTY) return false;
        return uint256(keccak256(abi.encodePacked(miner, ch, nonce))) >> (256 - uint256(t)) == 0;
    }

    // ----------------------------------------------------------- settlement

    function _min(uint256 a, uint256 b) private pure returns (uint256) {
        return a < b ? a : b;
    }

    /// @dev Pot of a round with `work` > 0 and snapshot `p` against `available`.
    function _pot(uint256 work, Params memory p, uint256 available) private pure returns (uint256 pot) {
        pot = _min(uint256(p.minRoundReward) + work * p.rewardPerWork, (available * p.poolCapBps) / BPS);
        if (pot > type(uint128).max) pot = type(uint128).max;
    }

    /// @notice Fix the pot of a closed round. Callable by anyone; idempotent.
    ///         The pot is read from the pool exactly once, then paid pro rata.
    function settleRound(uint256 rnd) public returns (uint256 pot) {
        require(rnd < currentRound(), "HashMine: round still open");
        RoundInfo storage r = _rounds[rnd];
        if (r.settled) return roundPot[rnd];
        uint256 work = r.work;
        if (work == 0) return 0; // nothing to pay; tokens stay available for later rounds
        r.settled = true;

        Params memory p = r.params;
        pot = _pot(work, p, availablePool());
        roundPot[rnd] = pot;
        _payouts[rnd] = RoundPayout(uint128(pot), uint128(work));
        reserved += pot;
        emit RoundSettled(rnd, work, p.rewardPerWork, p.minRoundReward, pot);
    }

    /// @notice Credit `miner`'s pro-rata share of closed round `rnd`.
    function settle(uint256 rnd, address miner) public {
        require(rnd < currentRound(), "HashMine: round still open");
        uint256 w = workOf[rnd][miner];
        if (w == 0) return;
        workOf[rnd][miner] = 0;
        settleRound(rnd);

        RoundPayout storage p = _payouts[rnd];
        uint256 potLeft = p.potLeft;
        uint256 workLeft = p.workLeft;
        uint256 share = workLeft == w ? potLeft : (potLeft * w) / workLeft;
        p.potLeft = uint128(potLeft - share);
        p.workLeft = uint128(workLeft - w);
        if (share != 0) accrued[miner] += share; // stays inside `reserved` until claimed
        emit Settled(rnd, miner, w, share);
    }

    function claim() external {
        uint256 rnd = currentRound();
        uint256[] storage mined = _roundsMined[msg.sender];
        uint256 i = claimIndex[msg.sender];
        uint256 stop = i + MAX_SETTLE_ROUNDS_PER_CLAIM;
        if (stop > mined.length) stop = mined.length;
        while (i < stop && mined[i] < rnd) {
            settle(mined[i], msg.sender);
            i++;
        }
        claimIndex[msg.sender] = i;

        uint256 amt = accrued[msg.sender];
        if (amt == 0) return;
        accrued[msg.sender] = 0;
        reserved -= amt;
        _safeTransfer(address(rewardToken), msg.sender, amt, "HashMine: transfer");
        emit Claimed(msg.sender, amt);
    }

    /// @notice Settle up to `maxRounds` ended rounds with work, oldest first.
    ///         Returns how many ended rounds are still unsettled afterwards.
    function settleBacklog(uint256 maxRounds) public returns (uint256 remaining) {
        uint256 cur = currentRound();
        uint256 i = settleCursor;
        uint256 end = _endedWorkRounds(cur);
        uint256 stop = maxRounds < end - i ? i + maxRounds : end;
        while (i < stop) {
            settleRound(_workRounds[i]); // no-op if already settled
            i++;
        }
        settleCursor = i;
        remaining = end - i;
    }

    /// @notice Ended rounds with work that settleBacklog/executeWithdraw still has to walk.
    function settleBacklogLength() public view returns (uint256) {
        return _endedWorkRounds(currentRound()) - settleCursor;
    }

    /// @dev Length of the prefix of _workRounds that has ended. The list is
    ///      strictly increasing and rounds only receive work while open, so
    ///      only the last entry can be the open round `cur`.
    function _endedWorkRounds(uint256 cur) private view returns (uint256 n) {
        n = _workRounds.length;
        if (n != 0 && _workRounds[n - 1] >= cur) n--;
    }

    /// @notice Tokens that must stay available so the open round can pay
    ///         minRoundReward + work * rate in full: its pot is
    ///         min(that, capBps * available / BPS), so available must stay
    ///         >= ceil(that * BPS / capBps). 0 while the open round has no work.
    function openRoundHoldback() public view returns (uint256) {
        RoundInfo storage r = _rounds[currentRound()];
        uint256 work = r.work;
        if (work == 0) return 0;
        Params memory p = r.params;
        uint256 need = uint256(p.minRoundReward) + work * p.rewardPerWork;
        return (need * BPS + p.poolCapBps - 1) / p.poolCapBps;
    }

    /// @notice What executeWithdraw would allow right now: balance - reserved -
    ///         pots of ended unsettled rounds (simulated in round order) - open
    ///         round holdback.
    ///         0 while the backlog is longer than executeWithdraw settles
    ///         itself (MAX_SETTLE_PER_WITHDRAW): call settleBacklog() first.
    function maxWithdrawable() external view returns (uint256) {
        uint256 cur = currentRound();
        uint256 end = _endedWorkRounds(cur);
        uint256 start = settleCursor;
        if (end - start > MAX_SETTLE_PER_WITHDRAW) return 0;
        uint256 avail = availablePool();
        for (uint256 i = start; i < end; i++) {
            uint256 rnd = _workRounds[i];
            RoundInfo storage r = _rounds[rnd];
            if (r.settled) continue;
            avail -= _pot(r.work, r.params, avail);
        }
        uint256 hold = openRoundHoldback();
        return avail > hold ? avail - hold : 0;
    }

    /// @notice The pending migration withdraw (unlockTime 0 => none).
    function pendingWithdraw() external view returns (address to, uint256 amount, uint256 unlockTime) {
        PendingWithdraw storage p = _pendingWithdraw;
        return (p.to, p.amount, p.unlockTime);
    }

    // ---------------------------------------------------------------- owner

    /// @notice Set the reward token. Owner only, exactly once; it can never be
    ///         changed afterwards. `token_` must be a contract that answers
    ///         balanceOf(address).
    function setToken(address token_) external onlyOwner {
        require(address(rewardToken) == address(0), "HashMine: token already set");
        require(token_ != address(0) && token_ != address(this), "HashMine: bad token");
        require(token_.code.length > 0, "HashMine: not a contract");
        (bool ok, bytes memory ret) =
            token_.staticcall(abi.encodeWithSelector(IERC20.balanceOf.selector, address(this)));
        require(ok && ret.length >= 32, "HashMine: not an ERC-20");
        rewardToken = IERC20(token_);
        emit TokenSet(token_);
    }

    function _checkBounds(uint8 id, uint256 v) private pure {
        if (id == PARAM_REWARD_PER_WORK) {
            require(v >= MIN_REWARD_PER_WORK && v <= MAX_REWARD_PER_WORK, "HashMine: bounds");
        } else if (id == PARAM_MIN_ROUND_REWARD) {
            require(v <= MAX_MIN_ROUND_REWARD, "HashMine: bounds");
        } else if (id == PARAM_POOL_CAP_BPS) {
            require(v >= MIN_POOL_CAP_BPS && v <= MAX_POOL_CAP_BPS, "HashMine: bounds");
        } else if (id == PARAM_PASS_MULTIPLIER_BPS) {
            require(v >= MIN_PASS_MULTIPLIER_BPS && v <= MAX_PASS_MULTIPLIER_BPS, "HashMine: bounds");
        } else if (id == PARAM_MAX_MULTIPLIER_BPS) {
            require(v >= MIN_MAX_MULTIPLIER_BPS && v <= MAX_MAX_MULTIPLIER_BPS, "HashMine: bounds");
        } else if (id == PARAM_WITHDRAW_DELAY) {
            require(v >= MIN_WITHDRAW_DELAY && v <= MAX_WITHDRAW_DELAY, "HashMine: bounds");
        } else {
            revert("HashMine: param id");
        }
    }

    /// @notice Propose setting parameter `id` to `value` (within its bounds).
    ///         Executable after PARAM_DELAY. A new proposal for the same id
    ///         replaces the pending one and restarts the timelock.
    function proposeParam(uint8 id, uint256 value) external onlyOwner {
        _checkBounds(id, value);
        uint256 eta = block.timestamp + PARAM_DELAY;
        _pendingParams[id] = PendingParam(value, uint64(eta));
        emit ParamProposed(id, value, eta);
    }

    function cancelParam(uint8 id) external onlyOwner {
        require(id < PARAM_COUNT, "HashMine: param id");
        PendingParam memory p = _pendingParams[id];
        require(p.eta != 0, "HashMine: no pending param");
        delete _pendingParams[id];
        emit ParamCancelled(id, p.value);
    }

    /// @notice Apply a proposed change once its timelock has passed. Round
    ///         parameters take effect from the next round; rounds that already
    ///         have work keep their snapshot regardless.
    function executeParam(uint8 id) external onlyOwner {
        require(id < PARAM_COUNT, "HashMine: param id");
        PendingParam memory p = _pendingParams[id];
        require(p.eta != 0, "HashMine: no pending param");
        require(block.timestamp >= p.eta, "HashMine: param timelocked");
        delete _pendingParams[id];
        uint256 v = p.value;

        if (id == PARAM_WITHDRAW_DELAY) {
            withdrawDelay = v;
            emit ParamExecuted(id, v, 0);
            return;
        }

        uint256 cur = currentRound();
        uint256 from = nextParamsFromRound;
        if (from == 0 || cur >= from) {
            // Roll any active schedule into the base: every round before `from`
            // is closed and, if it has work, snapshotted.
            if (from != 0) _params = _nextParams;
            _nextParams = _params;
            nextParamsFromRound = cur + 1;
        }
        Params storage q = _nextParams;
        if (id == PARAM_REWARD_PER_WORK) q.rewardPerWork = uint64(v);
        else if (id == PARAM_MIN_ROUND_REWARD) q.minRoundReward = uint96(v);
        else if (id == PARAM_POOL_CAP_BPS) q.poolCapBps = uint16(v);
        else if (id == PARAM_PASS_MULTIPLIER_BPS) q.passMultiplierBps = uint32(v);
        else q.maxMultiplierBps = uint32(v);
        emit ParamExecuted(id, v, nextParamsFromRound);
    }

    /// @notice Propose moving `amount` unowed reward tokens to `to` (e.g. a
    ///         successor contract). Executable after withdrawDelay. Only one
    ///         proposal can be pending; cancel it to propose another.
    function proposeWithdraw(address to, uint256 amount) external onlyOwner {
        require(to != address(0) && to != address(this), "HashMine: bad recipient");
        require(amount > 0, "HashMine: zero amount");
        require(_pendingWithdraw.unlockTime == 0, "HashMine: withdraw pending");
        uint256 unlockTime = block.timestamp + withdrawDelay;
        _pendingWithdraw = PendingWithdraw(to, uint96(unlockTime), amount);
        emit WithdrawProposed(to, amount, unlockTime);
    }

    function cancelWithdraw() external onlyOwner {
        PendingWithdraw memory p = _pendingWithdraw;
        require(p.unlockTime != 0, "HashMine: no pending withdraw");
        delete _pendingWithdraw;
        emit WithdrawCancelled(p.to, p.amount);
    }

    /// @notice Execute the pending withdraw once unlocked. Settles every ended
    ///         round first, then requires
    ///         amount <= balance - reserved - openRoundHoldback().
    function executeWithdraw() external onlyOwner tokenIsSet {
        PendingWithdraw memory p = _pendingWithdraw;
        require(p.unlockTime != 0, "HashMine: no pending withdraw");
        require(block.timestamp >= p.unlockTime, "HashMine: withdraw timelocked");
        require(settleBacklog(MAX_SETTLE_PER_WITHDRAW) == 0, "HashMine: settle backlog first");

        uint256 bal = rewardToken.balanceOf(address(this));
        uint256 owed = reserved + openRoundHoldback();
        require(bal > owed && p.amount <= bal - owed, "HashMine: exceeds unowed funds");

        delete _pendingWithdraw;
        _safeTransfer(address(rewardToken), p.to, p.amount, "HashMine: transfer");
        emit WithdrawExecuted(p.to, p.amount);
    }

    /// @notice Return a non-reward ERC-20 sent here by mistake. No timelock;
    ///         can never move the reward token.
    function rescueToken(address token, address to, uint256 amount) external onlyOwner tokenIsSet {
        require(token != address(rewardToken), "HashMine: reward token");
        require(to != address(0), "HashMine: bad recipient");
        _safeTransfer(token, to, amount, "HashMine: rescue transfer");
        emit TokenRescued(token, to, amount);
    }

    /// @dev ERC-20 transfer that accepts tokens returning no value and
    ///      reverts on `false`, a revert, or a non-contract token.
    function _safeTransfer(address token, address to, uint256 amount, string memory err) private {
        require(token.code.length > 0, "HashMine: not a contract");
        (bool ok, bytes memory ret) = token.call(abi.encodeWithSelector(IERC20.transfer.selector, to, amount));
        require(ok && (ret.length == 0 || (ret.length >= 32 && abi.decode(ret, (bool)))), err);
    }

    /// @notice Step 1 of 2: nominate `newOwner` (address(0) cancels a
    ///         nomination). Ownership moves only when newOwner calls
    ///         acceptOwnership(), so a typo cannot lose the contract.
    function transferOwnership(address newOwner) external onlyOwner {
        pendingOwner = newOwner;
        emit OwnershipTransferStarted(owner, newOwner);
    }

    /// @notice Step 2 of 2: the nominated owner takes ownership.
    function acceptOwnership() external {
        require(msg.sender == pendingOwner && msg.sender != address(0), "HashMine: not pending owner");
        delete pendingOwner;
        emit OwnershipTransferred(owner, msg.sender);
        owner = msg.sender;
    }

    /// @notice Give up ownership for good: no more param changes, withdraws or rescues.
    function renounceOwnership() external onlyOwner tokenIsSet {
        delete pendingOwner;
        emit OwnershipTransferred(owner, address(0));
        owner = address(0);
    }
}