VoomBoard

0xbe8d75da8e17f32090e5615bbf65536f1b5561c6

Verification
Verified
v0.8.24+commit.e11b9ed9
Type
Contract
7,400 bytes
ABI entries
30
16 read · 7 write
License
none

Contract information

Address
0xbe8d75da8e17f32090e5615bbf65536f1b5561c6
Chain
Robinhood Chain (4663)
Compiler
v0.8.24+commit.e11b9ed9
Optimization
Enabled
Creation tx
0xf7cc69fc07…a49c361d72

Token

Not a token

This contract does not expose ERC-20 metadata.

Read contract (16)

PROTOCOL_FEE_BPS()uint256
ROUND_DURATION()uint256
TILES()uint8
claimed(uint256, address)bool
currentRound()uint256
feeTo()address
feesAccrued()uint256
myStakes(uint256, address)uint256[25]
owner()address
rewardBudget()uint256
roundInfo(uint256)uint256, uint256, uint256, bool, uint8, uint256, uint256, uint256
stake(uint256, uint8, address)uint256
tileTotals(uint256)uint256[25]
timeLeft()uint256
voom()address
voomPerRound()uint256

Events (6)

ClaimedDeployFeesWithdrawnFinalizedRoundLockedRoundOpened

ABI

[
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "_voom",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "_feeTo",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "_voomPerRound",
        "type": "uint256"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "constructor"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "round",
        "type": "uint256"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "miner",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "payout",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "voomMinted",
        "type": "uint256"
      }
    ],
    "name": "Claimed",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "round",
        "type": "uint256"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "miner",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "Deploy",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "FeesWithdrawn",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "round",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint8",
        "name": "elim",
        "type": "uint8"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "survivorPot",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "survivorStake",
        "type": "uint256"
      }
    ],
    "name": "Finalized",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "round",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "settleBlock",
        "type": "uint256"
      }
    ],
    "name": "RoundLocked",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "round",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "start",
        "type": "uint256"
      }
    ],
    "name": "RoundOpened",
    "type": "event"
  },
  {
    "inputs": [],
    "name": "PROTOCOL_FEE_BPS",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "ROUND_DURATION",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "TILES",
    "outputs": [
      {
        "internalType": "uint8",
        "name": "",
        "type": "uint8"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "r",
        "type": "uint256"
      }
    ],
    "name": "claim",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      },
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "name": "claimed",
    "outputs": [
      {
        "internalType": "bool",
        "name": "",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "currentRound",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint8[]",
        "name": "tiles",
        "type": "uint8[]"
      },
      {
        "internalType": "uint256[]",
        "name": "amounts",
        "type": "uint256[]"
      }
    ],
    "name": "deploy",
    "outputs": [],
    "stateMutability": "payable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "feeTo",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "feesAccrued",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "r",
        "type": "uint256"
      }
    ],
    "name": "finalize",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "r",
        "type": "uint256"
      },
      {
        "internalType": "address",
        "name": "who",
        "type": "address"
      }
    ],
    "name": "myStakes",
    "outputs": [
      {
        "internalType": "uint256[25]",
        "name": "out",
        "type": "uint256[25]"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "owner",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "poke",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "rewardBudget",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "r",
        "type": "uint256"
      }
    ],
    "name": "roundInfo",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "start",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "endsAt",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "settleBlock",
        "type": "uint256"
      },
      {
        "internalType": "bool",
        "name": "finalized",
        "type": "bool"
      },
      {
        "internalType": "uint8",
        "name": "elim",
        "type": "uint8"
      },
      {
        "internalType": "uint256",
        "name": "total",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "survivorPot",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "survivorStake",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "_feeTo",
        "type": "address"
      }
    ],
    "name": "setFeeTo",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      },
      {
        "internalType": "uint8",
        "name": "",
        "type": "uint8"
      },
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "name": "stake",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "r",
        "type": "uint256"
      }
    ],
    "name": "tileTotals",
    "outputs": [
      {
        "internalType": "uint256[25]",
        "name": "",
        "type": "uint256[25]"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "timeLeft",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "o",
        "type": "address"
      }
    ],
    "name": "transferOwnership",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "voom",
    "outputs": [
      {
        "internalType": "contract VoomToken",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "voomPerRound",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "withdrawFees",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  }
]

Source code

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

import {VoomToken} from "./VoomToken.sol";

/// @title VoomBoard
/// @notice The Voom elimination lottery. Each 60s round, miners deploy ETH onto a
/// 5x5 board of 25 tiles. At settlement one tile is drawn and ELIMINATED (burned):
/// its ETH is shared among the survivors (everyone on the other 24 tiles), who also
/// get their own stake back, less a 10% protocol fee. Survivors also mint $VOOM.
/// Burn. Survive. Take it all.
///
/// $VOOM rewards come out of a pre-funded budget held by this contract, never from a
/// mint: `VoomToken` has no mint function at all, so there is no privileged role here
/// that a scanner could flag and no way for anyone to inflate the supply. If the budget
/// empties, `claim` simply pays 0 $VOOM and the ETH side keeps working untouched.
///
/// Randomness: commit-reveal on a future block hash. When a round closes it commits
/// to `block.number + 1`; settlement draws from that block's hash, so no one can
/// grind the outcome. (Pyth Entropy is the intended production upgrade.)
contract VoomBoard {
    uint8 public constant TILES = 25;
    uint256 public constant ROUND_DURATION = 60;
    uint256 public constant PROTOCOL_FEE_BPS = 1000; // 10%

    VoomToken public immutable voom;
    uint256 public immutable voomPerRound; // VOOM minted to survivors each round
    address public owner;
    address public feeTo;

    uint256 public currentRound;
    /// @notice Fees owed to `feeTo`, withdrawn with `withdrawFees`. Accrued rather than
    ///         pushed: a `feeTo` that reverts on receive would otherwise make `finalize`
    ///         revert forever, and since `claim` requires a finalized round that would
    ///         freeze every survivor's ETH permanently.
    uint256 public feesAccrued;

    struct Round {
        uint64 start;
        uint64 settleBlock; // 0 until the round is locked
        bool finalized;
        uint8 elim; // eliminated tile (valid once finalized)
        uint256 total; // total ETH deployed this round
        uint256 survivorPot; // ETH shared to survivors after fee (set at finalize)
        uint256 survivorStake; // total - tileTotal[elim] (set at finalize)
        uint256[25] tileTotal;
    }

    mapping(uint256 => Round) internal _rounds;
    // round => tile => player => ETH staked
    mapping(uint256 => mapping(uint8 => mapping(address => uint256))) public stake;
    mapping(uint256 => mapping(address => bool)) public claimed;

    uint256 private _lock = 1;
    modifier nonReentrant() {
        require(_lock == 1, "reentrant");
        _lock = 2;
        _;
        _lock = 1;
    }

    event Deploy(uint256 indexed round, address indexed miner, uint256 amount);
    event RoundLocked(uint256 indexed round, uint256 settleBlock);
    event RoundOpened(uint256 indexed round, uint256 start);
    event Finalized(uint256 indexed round, uint8 elim, uint256 survivorPot, uint256 survivorStake);
    event Claimed(uint256 indexed round, address indexed miner, uint256 payout, uint256 voomMinted);
    event FeesWithdrawn(address indexed to, uint256 amount);

    constructor(address _voom, address _feeTo, uint256 _voomPerRound) {
        require(_voom != address(0), "voom");
        require(_feeTo != address(0), "feeTo");
        owner = msg.sender;
        feeTo = _feeTo;
        voomPerRound = _voomPerRound;
        voom = VoomToken(_voom);
        currentRound = 1;
        _rounds[1].start = uint64(block.timestamp);
        emit RoundOpened(1, block.timestamp);
    }

    // ---- play ----
    function deploy(uint8[] calldata tiles, uint256[] calldata amounts) external payable nonReentrant {
        poke();
        uint256 r = currentRound;
        Round storage R = _rounds[r];
        require(block.timestamp < uint256(R.start) + ROUND_DURATION, "round closed");
        require(tiles.length == amounts.length && tiles.length > 0, "len");
        uint256 sum;
        for (uint256 i; i < tiles.length; i++) {
            uint8 t = tiles[i];
            uint256 a = amounts[i];
            require(t < TILES, "tile");
            require(a > 0, "amount");
            stake[r][t][msg.sender] += a;
            R.tileTotal[t] += a;
            sum += a;
        }
        require(sum == msg.value, "value");
        R.total += sum;
        emit Deploy(r, msg.sender, sum);
    }

    /// @notice advance the clock: lock an expired round and open the next one. Permissionless.
    function poke() public {
        Round storage R = _rounds[currentRound];
        if (R.settleBlock == 0 && block.timestamp >= uint256(R.start) + ROUND_DURATION) {
            R.settleBlock = uint64(block.number + 1);
            emit RoundLocked(currentRound, R.settleBlock);
            currentRound += 1;
            _rounds[currentRound].start = uint64(block.timestamp);
            emit RoundOpened(currentRound, block.timestamp);
        }
    }

    /// @notice draw the eliminated tile for a locked round once its commit block exists. Permissionless.
    function finalize(uint256 r) public nonReentrant {
        Round storage R = _rounds[r];
        require(R.settleBlock != 0 && !R.finalized, "not finalizable");
        require(block.number > R.settleBlock, "wait for block");

        bytes32 bh = blockhash(R.settleBlock);
        if (bh == bytes32(0)) {
            // The commit block aged out (>256 blocks) and its hash is gone. Re-commit to a
            // fresh future block instead of falling back to a hash the caller can already
            // read — that fallback would let anyone grind the draw by simulating finalize
            // each block and only broadcasting when the eliminated tile suits them.
            R.settleBlock = uint64(block.number + 1);
            emit RoundLocked(r, R.settleBlock);
            return;
        }

        uint8 elim = uint8(uint256(keccak256(abi.encodePacked(bh, r))) % TILES);
        R.elim = elim;
        R.finalized = true;

        uint256 pot = R.tileTotal[elim];
        uint256 surv = R.total - pot;
        R.survivorStake = surv;

        if (pot > 0) {
            if (surv == 0) {
                // no survivors (all ETH sat on the burned tile) → house takes the pot
                feesAccrued += pot;
            } else {
                uint256 fee = pot * PROTOCOL_FEE_BPS / 10000;
                R.survivorPot = pot - fee;
                feesAccrued += fee;
            }
        }
        emit Finalized(r, elim, R.survivorPot, surv);
    }

    /// @notice survivors claim: their surviving stake back + a pro-rata share of the burned pot + $VOOM.
    function claim(uint256 r) external nonReentrant {
        Round storage R = _rounds[r];
        require(R.finalized, "not finalized");
        require(!claimed[r][msg.sender], "claimed");
        claimed[r][msg.sender] = true;

        uint8 elim = R.elim;
        uint256 surviving;
        for (uint8 t; t < TILES; t++) {
            if (t == elim) continue;
            surviving += stake[r][t][msg.sender];
        }
        require(surviving > 0, "nothing to claim"); // burned-only stakes have nothing left

        uint256 share = R.survivorStake > 0 ? R.survivorPot * surviving / R.survivorStake : 0;
        uint256 payout = surviving + share;
        uint256 voomAmt = R.survivorStake > 0 ? voomPerRound * surviving / R.survivorStake : 0;

        // Pay out of the pre-funded budget, capped by what is actually left. A dry budget
        // must never block a survivor from getting their ETH back, so this is clamped
        // rather than required.
        if (voomAmt > 0) {
            uint256 budget = voom.balanceOf(address(this));
            if (voomAmt > budget) voomAmt = budget;
            if (voomAmt > 0) voom.transfer(msg.sender, voomAmt);
        }
        _send(msg.sender, payout);
        emit Claimed(r, msg.sender, payout, voomAmt);
    }

    /// @notice $VOOM left in the emission budget. Anyone can top it up with a plain transfer.
    function rewardBudget() external view returns (uint256) {
        return voom.balanceOf(address(this));
    }

    // ---- views ----
    function tileTotals(uint256 r) external view returns (uint256[25] memory) {
        return _rounds[r].tileTotal;
    }

    function myStakes(uint256 r, address who) external view returns (uint256[25] memory out) {
        for (uint8 t; t < TILES; t++) {
            out[t] = stake[r][t][who];
        }
    }

    function roundInfo(uint256 r)
        external
        view
        returns (uint256 start, uint256 endsAt, uint256 settleBlock, bool finalized, uint8 elim, uint256 total, uint256 survivorPot, uint256 survivorStake)
    {
        Round storage R = _rounds[r];
        return (R.start, uint256(R.start) + ROUND_DURATION, R.settleBlock, R.finalized, R.elim, R.total, R.survivorPot, R.survivorStake);
    }

    function timeLeft() external view returns (uint256) {
        uint256 endsAt = uint256(_rounds[currentRound].start) + ROUND_DURATION;
        return block.timestamp >= endsAt ? 0 : endsAt - block.timestamp;
    }

    /// @notice Pull accrued protocol fees to `feeTo`. Permissionless — it can only ever
    ///         pay `feeTo`, so anyone may trigger it and a stuck fee sink cannot hold the
    ///         game hostage.
    function withdrawFees() external nonReentrant {
        uint256 amount = feesAccrued;
        require(amount > 0, "no fees");
        feesAccrued = 0;
        _send(feeTo, amount);
        emit FeesWithdrawn(feeTo, amount);
    }

    // ---- admin (fee sink only) ----
    function setFeeTo(address _feeTo) external {
        require(msg.sender == owner, "only owner");
        feeTo = _feeTo;
    }

    function transferOwnership(address o) external {
        require(msg.sender == owner, "only owner");
        owner = o;
    }

    function _send(address to, uint256 amount) internal {
        (bool ok,) = to.call{value: amount}("");
        require(ok, "native send");
    }
}
Chain explorer5371msChain node81ms