// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; /// @title Blob Heist: ranked runs and weekly season pools on Elysium /// @notice Players pay a fixed HYPE entry per ranked run. A verifier replays each run off-chain /// from its committed seed and signs the score. When a season ends, every player can claim /// pool * theirPoints / totalPoints. The owner can't withdraw the prize pool. /// @dev UNAUDITED. Not deployed. Get an external audit before using it with real funds. contract BlobHeist { struct Season { bytes32 seedCommit; // keccak256(serverSeed), published before the season opens bytes32 seedReveal; // serverSeed, revealed after the season ends uint64 start; uint64 end; uint128 pool; // HYPE available to players (entry fees minus protocol fee) uint128 totalPoints; uint128 claimed; } struct Run { address player; uint64 seasonId; bool scored; bytes32 clientSeed; } uint256 public constant MAX_PROTOCOL_FEE_BPS = 1500; address public owner; address public verifier; // key that signs replay-verified scores address public treasury; uint256 public entryFee; uint256 public protocolFeeBps; uint256 public currentSeason; uint256 public nextRunId = 1; uint256 private locked = 1; mapping(uint256 => Season) public seasons; mapping(uint256 => Run) public runs; mapping(uint256 => mapping(address => uint256)) public pointsOf; mapping(uint256 => mapping(address => bool)) public hasClaimed; event SeasonOpened(uint256 indexed seasonId, bytes32 seedCommit, uint64 start, uint64 end); event SeedRevealed(uint256 indexed seasonId, bytes32 serverSeed); event RunEntered(uint256 indexed runId, address indexed player, uint256 indexed seasonId, bytes32 clientSeed, bytes32 runSeed); event RunScored(uint256 indexed runId, address indexed player, uint256 indexed seasonId, uint256 points); event Claimed(uint256 indexed seasonId, address indexed player, uint256 amount); event ConfigUpdated(address verifier, address treasury, uint256 entryFee, uint256 protocolFeeBps); error NotOwner(); error SeasonClosed(); error SeasonStillOpen(); error WrongFee(); error AlreadyScored(); error BadSignature(); error NothingToClaim(); error BadReveal(); error Reentrancy(); error TransferFailed(); error InvalidConfig(); modifier onlyOwner() { if (msg.sender != owner) revert NotOwner(); _; } modifier nonReentrant() { if (locked != 1) revert Reentrancy(); locked = 2; _; locked = 1; } constructor(address _verifier, address _treasury, uint256 _entryFee, uint256 _protocolFeeBps) { owner = msg.sender; _setConfig(_verifier, _treasury, _entryFee, _protocolFeeBps); } // ---------- Player actions ---------- /// @notice Pay the entry fee and start a ranked run in the current season. /// @param clientSeed Random value chosen by the player; it's mixed into the level seed. function enterRun(bytes32 clientSeed) external payable returns (uint256 runId) { Season storage s = seasons[currentSeason]; if (block.timestamp < s.start || block.timestamp >= s.end) revert SeasonClosed(); if (msg.value != entryFee) revert WrongFee(); uint256 fee = (msg.value * protocolFeeBps) / 10_000; s.pool += uint128(msg.value - fee); runId = nextRunId++; runs[runId] = Run({ player: msg.sender, seasonId: uint64(currentSeason), scored: false, clientSeed: clientSeed }); bytes32 runSeed = keccak256(abi.encode(s.seedCommit, clientSeed, runId, block.chainid)); emit RunEntered(runId, msg.sender, currentSeason, clientSeed, runSeed); if (fee > 0) _send(treasury, fee); } /// @notice Record a replay-verified score. Anyone can relay it; the verifier's signature is what counts. function submitRun(uint256 runId, uint256 points, bytes calldata signature) external { Run storage r = runs[runId]; if (r.player == address(0) || r.scored) revert AlreadyScored(); Season storage s = seasons[r.seasonId]; // Scores can arrive up to one hour after the season ends, so the last runs still count. if (block.timestamp >= uint256(s.end) + 1 hours) revert SeasonClosed(); bytes32 digest = _ethSigned(keccak256(abi.encode(address(this), block.chainid, runId, r.player, points))); if (_recover(digest, signature) != verifier) revert BadSignature(); r.scored = true; pointsOf[r.seasonId][r.player] += points; s.totalPoints += uint128(points); emit RunScored(runId, r.player, r.seasonId, points); } /// @notice Claim your share of a finished season: pool * yourPoints / totalPoints. function claim(uint256 seasonId) external nonReentrant { Season storage s = seasons[seasonId]; if (block.timestamp < uint256(s.end) + 1 hours) revert SeasonStillOpen(); uint256 pts = pointsOf[seasonId][msg.sender]; if (pts == 0 || hasClaimed[seasonId][msg.sender] || s.totalPoints == 0) revert NothingToClaim(); hasClaimed[seasonId][msg.sender] = true; uint256 amount = (uint256(s.pool) * pts) / s.totalPoints; s.claimed += uint128(amount); emit Claimed(seasonId, msg.sender, amount); _send(msg.sender, amount); } function claimable(uint256 seasonId, address player) external view returns (uint256) { Season storage s = seasons[seasonId]; if (hasClaimed[seasonId][player] || s.totalPoints == 0) return 0; return (uint256(s.pool) * pointsOf[seasonId][player]) / s.totalPoints; } // ---------- Owner actions ---------- /// @notice Open the next season. The previous one must be over. function openSeason(bytes32 seedCommit, uint64 start, uint64 end) external onlyOwner { if (end <= start) revert InvalidConfig(); Season storage prev = seasons[currentSeason]; if (prev.end != 0 && block.timestamp < prev.end) revert SeasonStillOpen(); uint256 id = prev.end == 0 ? currentSeason : currentSeason + 1; currentSeason = id; seasons[id].seedCommit = seedCommit; seasons[id].start = start; seasons[id].end = end; emit SeasonOpened(id, seedCommit, start, end); } /// @notice Publish the server seed so anyone can rebuild and audit every level of the season. function revealSeed(uint256 seasonId, bytes32 serverSeed) external onlyOwner { Season storage s = seasons[seasonId]; if (block.timestamp < s.end) revert SeasonStillOpen(); if (keccak256(abi.encode(serverSeed)) != s.seedCommit) revert BadReveal(); s.seedReveal = serverSeed; emit SeedRevealed(seasonId, serverSeed); } function setConfig(address _verifier, address _treasury, uint256 _entryFee, uint256 _protocolFeeBps) external onlyOwner { _setConfig(_verifier, _treasury, _entryFee, _protocolFeeBps); } function transferOwnership(address newOwner) external onlyOwner { if (newOwner == address(0)) revert InvalidConfig(); owner = newOwner; } // ---------- Internals ---------- function _setConfig(address _verifier, address _treasury, uint256 _entryFee, uint256 _protocolFeeBps) internal { if (_verifier == address(0) || _treasury == address(0) || _entryFee == 0 || _protocolFeeBps > MAX_PROTOCOL_FEE_BPS) revert InvalidConfig(); verifier = _verifier; treasury = _treasury; entryFee = _entryFee; protocolFeeBps = _protocolFeeBps; emit ConfigUpdated(_verifier, _treasury, _entryFee, _protocolFeeBps); } function _send(address to, uint256 amount) internal { (bool ok, ) = to.call{ value: amount }(""); if (!ok) revert TransferFailed(); } function _ethSigned(bytes32 h) internal pure returns (bytes32) { return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", h)); } function _recover(bytes32 digest, bytes calldata sig) internal pure returns (address) { if (sig.length != 65) return address(0); bytes32 r; bytes32 s; uint8 v; assembly { r := calldataload(sig.offset) s := calldataload(add(sig.offset, 32)) v := byte(0, calldataload(add(sig.offset, 64))) } if (v < 27) v += 27; // Reject malleable signatures (upper half of the curve order). if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) return address(0); return ecrecover(digest, v, r, s); } }