// SPDX-License-Identifier: MIT pragma solidity 0.8.30; import {Ownable, Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import {IRandomness, IBurnable} from "./Interfaces.sol"; /// @notice Fixed rules, one unresolved spin at a time, and fully reserved pull payments. /// @dev Shared accounting engine; concrete variants enforce their token admission rules. abstract contract JackpotGameCore is Ownable2Step, ReentrancyGuard { using SafeERC20 for IERC20; uint256 public constant ENTRY = 100_000 ether; uint256 public constant REWARD = 50_000 ether; uint256 public constant OUTCOMES = 400; IERC20 public immutable token; IRandomness public immutable randomness; address public immutable feeVault; uint256 public immutable maxOracleFee; bool public paused = true; bool public isShutdown; address payable public immutable recoveryRecipient; uint256 public totalRecovered; uint256 public pot; uint256 public operations; uint256 public totalTokenRewards; uint256 public totalEthRewards; uint256 public totalBurned; uint256 public nextSpinId = 1; uint256 public activeSpin; mapping(address => uint256) public tokenRewards; mapping(address => uint256) public ethRewards; mapping(address => uint256) public lastSpin; enum Status { None, Pending, Settled, Refunded } struct Spin { address player; uint64 sequence; Status status; uint16 outcome; uint256 lockedPot; bytes32 randomWord; } mapping(uint256 => Spin) public spins; error InvalidConfiguration(); error Unavailable(); error InexactTokenTransfer(); error NotReady(); error NothingToClaim(); error PaymentFailed(); event SpinRequested(uint256 indexed id, address indexed player, uint64 sequence, uint256 lockedPot); event SpinSettled(uint256 indexed id, address indexed player, uint16 outcome, uint256 tokensBurned, uint256 tokenReward, uint256 ethReward, bytes32 randomWord); event SpinRefunded(uint256 indexed id, address indexed player); event RewardsClaimed(address indexed player, address indexed recipient, uint256 tokens, uint256 eth); event PotFunded(uint256 amount); event OperationsFunded(uint256 amount); event PauseChanged(bool paused); event ShutdownRecovery(address indexed recipient, uint256 eth); event SurplusTokensRecovered(address indexed asset, uint256 amount); constructor(address owner_, address token_, address randomness_, address vault_, uint256 maxFee_) Ownable(owner_) { if (token_ == address(0) || randomness_.code.length == 0 || vault_.code.length == 0 || maxFee_ == 0) revert InvalidConfiguration(); token = IERC20(token_); randomness = IRandomness(randomness_); feeVault = vault_; maxOracleFee = maxFee_; recoveryRecipient = payable(owner_); } function _beforeSpin() internal virtual {} function acceptingEntries() public view virtual returns (bool) { return !paused && !isShutdown; } // Shutdown is permanent; accepted spins and accrued rewards remain fully backed. function setPaused(bool value) external onlyOwner { if (isShutdown && !value) revert Unavailable(); paused = value; emit PauseChanged(value); } function recoverableEth() public view returns (uint256) { uint256 locked = activeSpin == 0 ? 0 : spins[activeSpin].lockedPot; return address(this).balance - totalEthRewards - locked; } /// @notice Permanently stop new entries and recover all uncommitted ETH to the original dev wallet. /// Repeat after settlement/refund or a forced ETH receipt to recover newly available funds. function shutdownAndRecover() external onlyOwner nonReentrant { isShutdown = true; paused = true; uint256 amount = recoverableEth(); pot = 0; operations = 0; totalRecovered += amount; emit PauseChanged(true); emit ShutdownRecovery(recoveryRecipient, amount); if (amount != 0) { (bool ok,) = recoveryRecipient.call{value: amount}(""); if (!ok) revert PaymentFailed(); } } function recoverSurplusTokens(address asset) external onlyOwner nonReentrant { if (!isShutdown || asset.code.length == 0) revert Unavailable(); uint256 reserved = asset == address(token) ? totalTokenRewards + (activeSpin == 0 ? 0 : ENTRY) : 0; IERC20 coin = IERC20(asset); uint256 amount = coin.balanceOf(address(this)) - reserved; if (amount != 0) coin.safeTransfer(recoveryRecipient, amount); if (coin.balanceOf(address(this)) < reserved) revert InexactTokenTransfer(); emit SurplusTokensRecovered(asset, amount); } function fundPot() external payable { if (msg.sender != feeVault || isShutdown) revert InvalidConfiguration(); pot += msg.value; emit PotFunded(msg.value); } function fundOperations() external payable { // The oracle must still be able to return an accepted request's fee after shutdown. if (isShutdown && msg.sender != address(randomness)) revert Unavailable(); operations += msg.value; emit OperationsFunded(msg.value); } /// @param minimumPot Protects the user's quoted prize if another player wins first. /// @param deadline Rejects a stale transaction; it never cancels an accepted spin. function spin(bytes32 contribution, uint256 minimumPot, uint256 deadline) external nonReentrant returns (uint256 id) { _beforeSpin(); if (paused || activeSpin != 0 || pot == 0 || pot < minimumPot || block.timestamp > deadline || contribution == bytes32(0)) revert Unavailable(); if (randomness.game() != address(this)) revert InvalidConfiguration(); uint256 fee = randomness.quote(); if (fee == 0 || fee > maxOracleFee || fee > operations) revert Unavailable(); uint256 beforeBalance = token.balanceOf(address(this)); token.safeTransferFrom(msg.sender, address(this), ENTRY); if (token.balanceOf(address(this)) != beforeBalance + ENTRY) revert InexactTokenTransfer(); id = nextSpinId++; activeSpin = id; lastSpin[msg.sender] = id; uint256 locked = pot; pot = 0; operations -= fee; // External oracle may only respond asynchronously. The entry and prize are already locked. uint64 sequence = randomness.request{value: fee}(keccak256(abi.encode(contribution, msg.sender, id, block.chainid, address(this)))); spins[id] = Spin(msg.sender, sequence, Status.Pending, 0, locked, bytes32(0)); emit SpinRequested(id, msg.sender, sequence, locked); } function outcomeFor(bytes32 word) public pure returns (uint16) { uint256 value = uint256(word); uint256 limit = type(uint256).max - (type(uint256).max % OUTCOMES); while (value >= limit) value = uint256(keccak256(abi.encode(value))); // The remainder is bounded to 0–399, so 1–400 fits in uint16. return uint16(value % OUTCOMES + 1); } // Anyone can finalize; they cannot select an outcome or recipient. No payout in callback. function settle(uint256 id) external nonReentrant { Spin storage item = spins[id]; if (id != activeSpin || item.status != Status.Pending) revert NotReady(); (bool ready, bytes32 word) = randomness.result(item.sequence); if (!ready) revert NotReady(); uint16 outcome = outcomeFor(word); uint256 reward = outcome > 4 && outcome <= 84 ? REWARD : 0; uint256 prize = outcome <= 4 ? item.lockedPot : 0; item.status = Status.Settled; item.outcome = outcome; item.randomWord = word; activeSpin = 0; tokenRewards[item.player] += reward; totalTokenRewards += reward; ethRewards[item.player] += prize; totalEthRewards += prize; if (prize == 0) pot += item.lockedPot; uint256 burned = ENTRY - reward; uint256 supplyBefore = IERC20Metadata(address(token)).totalSupply(); uint256 balanceBefore = token.balanceOf(address(this)); IBurnable(address(token)).burn(burned); if (token.balanceOf(address(this)) + burned != balanceBefore || IERC20Metadata(address(token)).totalSupply() + burned != supplyBefore) revert InexactTokenTransfer(); totalBurned += burned; emit SpinSettled(id, item.player, outcome, burned, reward, prize, word); } // No local timeout fallback: oracle must prove the request was invalidated unrevealed. function refundUnrevealed(uint256 id) external nonReentrant { Spin storage item = spins[id]; if (id != activeSpin || item.status != Status.Pending) revert NotReady(); uint256 beforeOperations = operations; uint256 refundedFee = randomness.cancel(item.sequence); if (refundedFee == 0 || operations != beforeOperations + refundedFee) revert InvalidConfiguration(); item.status = Status.Refunded; activeSpin = 0; pot += item.lockedPot; tokenRewards[item.player] += ENTRY; totalTokenRewards += ENTRY; emit SpinRefunded(id, item.player); } function claim(address payable recipient) external nonReentrant { _claim(msg.sender, recipient); } // Lets a keeper sponsor claim gas, always paying the recorded player's wallet. function claimFor(address player) external nonReentrant { _claim(player, payable(player)); } function _claim(address player, address payable recipient) private { if (recipient == address(0) || recipient == address(this)) revert InvalidConfiguration(); uint256 tokens = tokenRewards[player]; uint256 eth = ethRewards[player]; if (tokens == 0 && eth == 0) revert NothingToClaim(); tokenRewards[player] = 0; ethRewards[player] = 0; totalTokenRewards -= tokens; totalEthRewards -= eth; if (tokens != 0) { uint256 beforeBalance = token.balanceOf(recipient); token.safeTransfer(recipient, tokens); if (token.balanceOf(recipient) != beforeBalance + tokens) revert InexactTokenTransfer(); } if (eth != 0) { (bool ok,) = recipient.call{value: eth}(""); if (!ok) revert PaymentFailed(); } emit RewardsClaimed(player, recipient, tokens, eth); } }