// SPDX-License-Identifier: MIT pragma solidity 0.8.30; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; /// The one thing the Grand game asks of a sign game before letting it join. interface ISignGameGrand { function grand() external view returns (address); } /// @title GrandGame /// @notice The "last to lock wins" game across every launched zodiac sign. It runs from the first sign and grows as /// signs join: the owner adds each sign's game when that sign launches (append-only, at most twelve, never removed). /// /// Its pot is what joined signs send it: 10% of every fee payment each sign collects from Pons, a fifth of the game's /// half. It holds no tokens itself; locks stay in the sign games. Every lock on a joined sign is reported here, and /// it counts when it is worth at least `minEntryNow()` (5% of this pot at launch settings): it becomes the last entry /// and puts this clock back to full. When the clock runs out, the last entry wins half the pot and the other half /// stays for the next round. contract GrandGame is ReentrancyGuard { uint256 private constant BPS = 10_000; /// The winner's share of the pot when the clock runs out. The rest (50%) stays for the next round. uint256 public constant WINNER_BPS = 5_000; uint256 public constant MAX_SIGNS = 12; /// @notice Gas given to a winner's wallet when its prize is sent; a wallet that refuses keeps it to claim. uint256 public constant PAYOUT_GAS = 60_000; struct Settings { uint32 clockLength; // seconds the clock runs after each entry uint128 minEntry; // smallest lock that counts here, in wei of value uint16 minEntryPotBps; // an entry must also be worth at least this share of the pot uint128 startFloor; // the clock only starts once the pot holds at least this much } struct Round { address winner; address winnerSign; uint256 winnerPrize; } /// May add signs, and do nothing else. address public immutable owner; uint256 public immutable clockLength; uint256 public immutable minEntry; uint256 public immutable minEntryPotBps; uint256 public immutable startFloor; address[] public signs; mapping(address => bool) public isSign; uint256 public pot; uint256 public owed; mapping(address => uint256) public prizeOf; uint256 public round = 1; address public lastLocker; address public lastSign; uint256 public lastLockAt; uint256 public deadline; mapping(uint256 => Round) public rounds; event SignAdded(address indexed sign, uint256 count); event Funded(address indexed from, uint256 amount); event Entered(uint256 indexed round, address indexed locker, address indexed sign, uint256 value, uint256 deadline); event RoundSettled( uint256 indexed round, address indexed winner, address sign, uint256 winnerPrize, uint256 potLeft ); event Claimed(address indexed locker, uint256 amount); event PaidOut(address indexed winner, uint256 amount); error NotOwner(); error NotSign(); error AlreadySign(); error TooManySigns(); error WrongGrand(); error BadSettings(); error ClockRunning(); error NothingToClaim(); error SendFailed(); constructor(address owner_, Settings memory settings) { if (owner_ == address(0) || settings.clockLength == 0 || settings.minEntryPotBps > BPS) revert BadSettings(); owner = owner_; clockLength = settings.clockLength; minEntry = settings.minEntry; minEntryPotBps = settings.minEntryPotBps; startFloor = settings.startFloor; } /// @notice Let a newly launched sign's game join. It must already point at this Grand game. function addSign(address game) external { if (msg.sender != owner) revert NotOwner(); if (isSign[game]) revert AlreadySign(); if (signs.length >= MAX_SIGNS) revert TooManySigns(); if (ISignGameGrand(game).grand() != address(this)) revert WrongGrand(); isSign[game] = true; signs.push(game); emit SignAdded(game, signs.length); } /// @notice Called by a joined sign game for every lock there. Locks worth less than `minEntryNow()` only count /// in their sign's own game. function recordLock(address locker, uint256 value) external { if (!isSign[msg.sender]) revert NotSign(); if (deadline != 0 && block.timestamp >= deadline) _settle(); if (value < minEntryNow()) return; uint256 id = round; lastLocker = locker; lastSign = msg.sender; lastLockAt = block.timestamp; if (deadline != 0 || pot >= startFloor) deadline = block.timestamp + clockLength; emit Entered(id, locker, msg.sender, value, deadline); } /// @notice End the round once its clock has run out and send the winner their prize. Anyone may call it. The /// next entry also ends it, but only credits the prize, since a sign's lock leaves this game little gas. function settle() external nonReentrant { if (deadline == 0 || block.timestamp < deadline) revert ClockRunning(); _pay(_settle()); } /// @notice Send `winner` every prize they have won and not yet received. Anyone may call it, so a win never waits /// on the winner; if the clock has run out it settles that round first. Round shares stay with claim(). function payOut(address winner) external nonReentrant returns (uint256 amount) { if (deadline != 0 && block.timestamp >= deadline) _settle(); amount = _pay(winner); if (amount == 0) revert NothingToClaim(); } /// @notice Withdraw your winner prizes: ones credited when a sign's lock ended the round, or ones your wallet /// refused. Anyone can also send them to you with `payOut`. function claim() external nonReentrant returns (uint256 amount) { amount = prizeOf[msg.sender]; prizeOf[msg.sender] = 0; if (amount == 0) revert NothingToClaim(); owed -= amount; (bool sent,) = msg.sender.call{value: amount}(""); if (!sent) revert SendFailed(); emit Claimed(msg.sender, amount); } // --------------------------------------------------------------------- // Views // --------------------------------------------------------------------- /// @notice The smallest lock, in ETH of value, that counts here right now. function minEntryNow() public view returns (uint256) { uint256 potMin = (pot * minEntryPotBps) / BPS; return potMin > minEntry ? potMin : minEntry; } function signCount() external view returns (uint256) { return signs.length; } function timeLeft() external view returns (uint256) { return deadline > block.timestamp ? deadline - block.timestamp : 0; } // --------------------------------------------------------------------- // Internals // --------------------------------------------------------------------- /// @dev Ends the round and credits the winner; returns who won. function _settle() private returns (address winner) { winner = lastLocker; uint256 id = round; uint256 p = pot; uint256 prize = (p * WINNER_BPS) / BPS; rounds[id] = Round({winner: lastLocker, winnerSign: lastSign, winnerPrize: prize}); prizeOf[lastLocker] += prize; owed += prize; pot = p - prize; deadline = 0; round = id + 1; emit RoundSettled(id, lastLocker, lastSign, prize, pot); } /// @dev Sends `to` their credited prizes with capped gas. On a refusal the credit stays for them to claim. function _pay(address to) private returns (uint256 amount) { amount = prizeOf[to]; if (amount == 0) return 0; prizeOf[to] = 0; owed -= amount; (bool sent,) = to.call{value: amount, gas: PAYOUT_GAS}(""); if (!sent) { prizeOf[to] = amount; owed += amount; return 0; } emit PaidOut(to, amount); } /// @dev Joined signs send their Grand share here. Anything else sent is a donation; both grow the pot. receive() external payable { pot += msg.value; emit Funded(msg.sender, msg.value); } }