// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; /// @dev Paintswap's native-payment coordinator interface. The configured coordinator verifies proofs. interface ITwoMuchVRFV3 { function calculateRequestPriceNative(uint256 callbackGasLimit) external view returns (uint256); function requestRandomnessPayInNative(uint256 callbackGasLimit, uint256 numWords, address refundee) external payable returns (uint256); } /// @notice ETH-only bank. Pricing and physics are attested by an operator locked on first activation. /// @dev Starts paused. Neither this contract nor a VRF proves honest physics or USD pricing. /// Fees are reserved until a token and fixed buyback adapter are bound once. No owner sweep. interface IMuchBurnToken { function balanceOf(address account) external view returns (uint256); function totalSupply() external view returns (uint256); function burn(uint256 amount) external; } interface IMuchBuyback { function token() external view returns (address); function game() external view returns (address); function buy(uint256 minimumTokens, uint256 deadline) external payable returns (uint256); } contract TwoMuchGameV3 is ReentrancyGuard { enum State { Open, DrawReady, AwaitingRandomness, RandomReady, Resolved, Refunding } struct Round { State state; uint64 deadline; uint256 entryCount; uint256 activeMove; uint256 pot; uint256 requestId; bytes32 stateHash; address winner; } struct Quote { address player; uint256 roundId; uint256 turn; bytes32 beforeState; uint8 shape; uint16 x; uint16 angle; uint256 amount; uint256 usdCents; uint256 nonce; uint256 deadline; } struct Move { uint256 roundId; address player; uint8 status; } // 1 pending, 2 success, 3 crash uint256 public constant API_VERSION = 3; uint256 public constant MOVE_TIMEOUT = 5 minutes; uint256 public constant ROUND_TIMEOUT = 1 hours; uint256 public constant RANDOM_TIMEOUT = 1 days; uint256 public constant CALLBACK_GAS = 140_000; bytes32 public constant EMPTY_STATE = keccak256("2MUCH/physics-v1/empty"); bytes32 private constant QUOTE_TYPEHASH = keccak256("Quote(address player,uint256 roundId,uint256 turn,bytes32 beforeState,uint8 shape,uint16 x,uint16 angle,uint256 amount,uint256 usdCents,uint256 nonce,uint256 deadline)"); address public immutable administrator; address public burnToken; address public burnAdapter; bytes32 public burnAdapterCodeHash; uint256 public burnReserve; uint256 public totalBurned; uint256 public burnCount; uint256 public burnConfiguredAt; address public operator; bool public configurationLocked; ITwoMuchVRFV3 public immutable coordinator; bool public paused = true; uint256 public currentRound = 1; uint256 public nextMoveId = 1; uint256 public randomnessBudget; mapping(uint256 => Round) public rounds; mapping(uint256 => Move) public moves; mapping(uint256 => uint256[]) private ticketMoves; mapping(uint256 => mapping(address => uint256)) public deposits; mapping(address => uint256) public credits; mapping(address => mapping(uint256 => bool)) public usedNonces; mapping(uint256 => uint256) public requestRound; mapping(uint256 => uint256) public randomWords; mapping(uint256 => address[]) private players; mapping(uint256 => uint256) public refundCursor; error InvalidConfig(); error WrongChain(); error Unauthorized(); error InvalidState(); error InvalidQuote(); error WrongPayment(); error Expired(); error InsufficientBudget(); error NothingToClaim(); error TransferFailed(); error InsufficientExecutionGas(); event AutoPaid(address indexed account, uint256 amount); event PaymentDeferred(address indexed account, uint256 amount); event PauseChanged(bool paused); event OperatorConfigured(address indexed operator); event RoundOpened(uint256 indexed roundId); event MovePaid(uint256 indexed roundId, uint256 indexed moveId, address indexed player, uint8 shape, uint16 x, uint16 angle, uint256 amount, bytes32 beforeState); event MoveResolved(uint256 indexed roundId, uint256 indexed moveId, bool successful, bytes32 stateHash); event DrawRequested(uint256 indexed roundId, uint256 indexed requestId, uint256 cost); event RandomnessRecorded(uint256 indexed roundId, uint256 indexed requestId); event RoundWon(uint256 indexed roundId, address indexed winner, uint256 winningMove, uint256 prize, uint256 fee); event RefundsEnabled(uint256 indexed roundId); event Claimed(address indexed account, address indexed recipient, uint256 amount); event RandomnessFunded(address indexed sender, uint256 amount); constructor(address operator_, address coordinator_) { if (block.chainid != 4663 && block.chainid != 46630 && block.chainid != 31337) revert WrongChain(); if (coordinator_.code.length == 0) revert InvalidConfig(); administrator = msg.sender; operator = operator_; coordinator = ITwoMuchVRFV3(coordinator_); rounds[1].stateHash = EMPTY_STATE; rounds[1].deadline = uint64(block.timestamp + ROUND_TIMEOUT); emit RoundOpened(1); } function DOMAIN_SEPARATOR() public view returns (bytes32) { return keccak256(abi.encode(keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), keccak256("2MUCH ETH Game"), keccak256("3"), block.chainid, address(this))); } function ticketCount(uint256 roundId) external view returns (uint256) { return ticketMoves[roundId].length; } function ticketAt(uint256 roundId, uint256 index) external view returns (address player, uint256 moveId) { moveId = ticketMoves[roundId][index]; player = moves[moveId].player; } /// @notice Pausing blocks new entries, never withdrawals, refunds or settling existing moves. function setPaused(bool value) external { if (msg.sender != administrator) revert Unauthorized(); if (!value) { if (operator == address(0)) revert InvalidConfig(); configurationLocked = true; } paused = value; if (!value && rounds[currentRound].entryCount == 0 && rounds[currentRound].state == State.Open) rounds[currentRound].deadline = uint64(block.timestamp + ROUND_TIMEOUT); emit PauseChanged(value); } /// @notice Prepare the server before opening entries for the first time; cannot change it afterwards. function configureOperator(address operator_) external { if (msg.sender != administrator) revert Unauthorized(); if (configurationLocked || operator_ == address(0)) revert InvalidConfig(); operator = operator_; emit OperatorConfigured(operator_); } function fundRandomness() external payable { randomnessBudget += msg.value; emit RandomnessFunded(msg.sender, msg.value); } /// @dev Coordinator gas refunds replenish the separate service budget, not the player pot. receive() external payable { if (msg.sender != address(coordinator)) revert Unauthorized(); randomnessBudget += msg.value; emit RandomnessFunded(msg.sender, msg.value); } function withdrawUnusedBudget(address payable recipient, uint256 amount) external nonReentrant { State state = rounds[currentRound].state; if (msg.sender != administrator) revert Unauthorized(); if (!paused || recipient == address(0) || amount > randomnessBudget || !((state == State.Open && rounds[currentRound].entryCount == 0) || state == State.Resolved || state == State.Refunding)) revert InvalidState(); randomnessBudget -= amount; _send(recipient, amount); } function place(Quote calldata q, bytes calldata signature) external payable nonReentrant returns (uint256 moveId) { Round storage r = rounds[currentRound]; if (paused || r.state != State.Open || r.activeMove != 0) revert InvalidState(); if (block.timestamp > r.deadline) revert Expired(); if (q.player != msg.sender || q.roundId != currentRound || q.turn != r.entryCount + 1 || q.beforeState != r.stateHash || q.shape > 3 || q.x < 80 || q.x > 920 || q.x % 5 != 0 || q.angle >= 180 || q.angle % 15 != 0 || q.usdCents != 100 || q.amount < 10 || usedNonces[msg.sender][q.nonce] || q.deadline < block.timestamp || q.deadline > block.timestamp + 2 minutes) revert InvalidQuote(); bytes32 structHash = keccak256(abi.encode(QUOTE_TYPEHASH, q)); if (ECDSA.recover(keccak256(abi.encodePacked(hex"1901", DOMAIN_SEPARATOR(), structHash)), signature) != operator) revert InvalidQuote(); if (msg.value != q.amount) revert WrongPayment(); if (randomnessBudget < coordinator.calculateRequestPriceNative(CALLBACK_GAS)) revert InsufficientBudget(); usedNonces[msg.sender][q.nonce] = true; moveId = nextMoveId++; moves[moveId] = Move(currentRound, msg.sender, 1); r.activeMove = moveId; r.entryCount++; r.pot += msg.value; if (deposits[currentRound][msg.sender] == 0) players[currentRound].push(msg.sender); deposits[currentRound][msg.sender] += msg.value; r.deadline = uint64(block.timestamp + MOVE_TIMEOUT); emit MovePaid(currentRound, moveId, msg.sender, q.shape, q.x, q.angle, msg.value, q.beforeState); } /// @notice Trusted authoritative physics attestation; the browser cannot settle itself. function resolveMove(uint256 moveId, bool successful, bytes32 stateHash) external nonReentrant { if (msg.sender != operator) revert Unauthorized(); Round storage r = rounds[currentRound]; Move storage m = moves[moveId]; if (r.state != State.Open || r.activeMove != moveId || m.roundId != currentRound || m.status != 1 || stateHash == bytes32(0)) revert InvalidState(); if (block.timestamp > r.deadline) revert Expired(); r.activeMove = 0; r.stateHash = stateHash; m.status = successful ? 2 : 3; emit MoveResolved(currentRound, moveId, successful, stateHash); if (successful) { ticketMoves[currentRound].push(moveId); r.deadline = uint64(block.timestamp + ROUND_TIMEOUT); } else if (ticketMoves[currentRound].length == 0) { _beginRefund(currentRound); } else { r.state = State.DrawReady; r.deadline = uint64(block.timestamp + RANDOM_TIMEOUT); } } /// @notice Tickets are already frozen. Exactly one randomness request per round. function requestDraw(uint256 roundId) external nonReentrant { Round storage r = rounds[roundId]; if (roundId != currentRound || r.state != State.DrawReady) revert InvalidState(); if (block.timestamp > r.deadline) revert Expired(); uint256 cost = coordinator.calculateRequestPriceNative(CALLBACK_GAS); if (cost > randomnessBudget) revert InsufficientBudget(); randomnessBudget -= cost; r.state = State.AwaitingRandomness; uint256 requestId = coordinator.requestRandomnessPayInNative{value:cost}(CALLBACK_GAS, 1, address(this)); if (requestId == 0 || requestRound[requestId] != 0) revert InvalidState(); r.requestId = requestId; requestRound[requestId] = roundId; emit DrawRequested(roundId, requestId, cost); } /// @dev Bounded callback: store once, no transfer or ticket iteration. A late result cannot undo refunds. function rawFulfillRandomWords(uint256 requestId, uint256[] calldata words) external { if (msg.sender != address(coordinator)) revert Unauthorized(); uint256 roundId = requestRound[requestId]; Round storage r = rounds[roundId]; if (roundId == 0 || r.state != State.AwaitingRandomness || words.length != 1 || block.timestamp > r.deadline) return; randomWords[roundId] = words[0]; r.state = State.RandomReady; emit RandomnessRecorded(roundId, requestId); } function settle(uint256 roundId) external nonReentrant { _settle(roundId); } function _settle(uint256 roundId) private { Round storage r = rounds[roundId]; if (r.state != State.RandomReady) revert InvalidState(); uint256 winningMove = ticketMoves[roundId][randomWords[roundId] % ticketMoves[roundId].length]; address winner = moves[winningMove].player; uint256 total = r.pot; uint256 fee = total / 10; r.pot = 0; r.state = State.Resolved; r.winner = winner; credits[winner] += total - fee; burnReserve += fee; emit BurnReserved(roundId, fee); emit RoundWon(roundId, winner, winningMove, total - fee, fee); _tryPay(winner); } /// @notice Full entry refunds on inactivity/service timeout. VRF service gas is externally funded. /// @dev Provider availability remains a trust assumption: withholding can force a timeout, never a redraw. function expireRound(uint256 roundId) external nonReentrant { if (roundId == 0 || roundId > currentRound) revert InvalidState(); Round storage r = rounds[roundId]; if ((r.state != State.Open && r.state != State.DrawReady && r.state != State.AwaitingRandomness) || block.timestamp <= r.deadline) revert InvalidState(); _beginRefund(roundId); } function refund(uint256 roundId, address payable recipient) external nonReentrant { if (rounds[roundId].state != State.Refunding || recipient == address(0)) revert InvalidState(); uint256 amount = deposits[roundId][msg.sender]; if (amount == 0) revert NothingToClaim(); deposits[roundId][msg.sender] = 0; rounds[roundId].pot -= amount; _send(recipient, amount); emit Claimed(msg.sender, recipient, amount); } function claim(address payable recipient) external nonReentrant { if (recipient == address(0)) revert InvalidConfig(); uint256 amount = credits[msg.sender]; if (amount == 0) revert NothingToClaim(); credits[msg.sender] = 0; _send(recipient, amount); emit Claimed(msg.sender, recipient, amount); } /// @notice Retry deferred ETH delivery; callers cannot redirect another account's funds. function pay(address account) external nonReentrant { if (credits[account] == 0) revert NothingToClaim(); _tryPay(account); } function refundProgress(uint256 roundId) external view returns (uint256 done, uint256 total) { return (refundCursor[roundId], players[roundId].length); } function _beginRefund(uint256 roundId) private { Round storage r = rounds[roundId]; r.state = State.Refunding; r.activeMove = 0; emit RefundsEnabled(roundId); _processRefunds(roundId, 4); } /// @notice Anyone can advance the bounded refund queue; each payment goes to its depositor. function processRefunds(uint256 roundId, uint256 limit) external nonReentrant { if (rounds[roundId].state != State.Refunding || limit == 0 || limit > 20) revert InvalidState(); _processRefunds(roundId, limit); } function _processRefunds(uint256 roundId, uint256 limit) private { if (refundCursor[roundId] < players[roundId].length && gasleft() < 250_000) revert InsufficientExecutionGas(); uint256 end = refundCursor[roundId] + limit; if (end > players[roundId].length) end = players[roundId].length; while (refundCursor[roundId] < end && gasleft() > 200_000) { address player = players[roundId][refundCursor[roundId]++]; uint256 amount = deposits[roundId][player]; if (amount == 0) continue; deposits[roundId][player] = 0; rounds[roundId].pot -= amount; credits[player] += amount; _tryPay(player); } } function _tryPay(address account) private { uint256 amount = credits[account]; if (amount == 0) return; if (gasleft() < 90_000) revert InsufficientExecutionGas(); credits[account] = 0; (bool ok,) = payable(account).call{value: amount, gas: 50_000}(""); if (ok) emit AutoPaid(account, amount); else { credits[account] = amount; emit PaymentDeferred(account, amount); } } function _send(address payable recipient, uint256 amount) private { (bool ok,) = recipient.call{value:amount}(""); if (!ok) revert TransferFailed(); } error BurnUnavailable(); error BurnVerificationFailed(); event BurnConfigured(address indexed token, address indexed adapter, bytes32 codeHash); event BurnReserved(uint256 indexed roundId, uint256 ethAmount); event SupplyBurned(uint256 indexed burnId, uint256 ethSpent, uint256 tokensBurned, uint256 supplyAfter); event BuybackDeferred(uint256 indexed roundId, uint256 ethAmount); /// @notice Bind the real token and reviewed immutable market route once, after token launch. /// @dev Pausing new entries does not prevent settling/refunding old entries. Binding never spends funds. function configureBurn(address token_, address adapter_) external nonReentrant { if (msg.sender != administrator) revert Unauthorized(); if (!paused || burnToken != address(0) || token_.code.length == 0 || adapter_.code.length == 0 || token_ == address(this) || adapter_ == address(this)) revert InvalidConfig(); if (IMuchBuyback(adapter_).token() != token_ || IMuchBuyback(adapter_).game() != address(this) || IMuchBurnToken(token_).totalSupply() == 0) revert InvalidConfig(); burnToken = token_; burnAdapter = adapter_; burnAdapterCodeHash = adapter_.codehash; burnConfiguredAt = block.timestamp; emit BurnConfigured(token_, adapter_, burnAdapterCodeHash); } /// @notice The existing trusted operator supplies a fresh minimum output. Anyone can still settle the prize alone. function settleAndBurn(uint256 roundId, uint256 amount, uint256 minimumTokens, uint256 deadline) external nonReentrant { if (msg.sender != operator) revert Unauthorized(); if (gasleft() < 1_600_000) revert InsufficientExecutionGas(); _settle(roundId); // A failed market/burn call rolls back only the buyback, never the completed prize payment. try this.executePendingBurn{gas:1_200_000}(amount, minimumTokens, deadline) {} catch { emit BuybackDeferred(roundId, amount); } } /// @notice Retry accumulated fees only; no player pot, protected credit or VRF reserve can be spent. function processBurn(uint256 amount, uint256 minimumTokens, uint256 deadline) external nonReentrant { if (msg.sender != operator) revert Unauthorized(); _burnFees(amount, minimumTokens, deadline); } /// @dev Self-call creates the revert boundary used by settleAndBurn; not a public bypass of the guard. function executePendingBurn(uint256 amount, uint256 minimumTokens, uint256 deadline) external { if (msg.sender != address(this) || !_reentrancyGuardEntered()) revert Unauthorized(); _burnFees(amount, minimumTokens, deadline); } function _burnFees(uint256 amount, uint256 minimumTokens, uint256 deadline) private { if (burnToken == address(0) || block.timestamp < burnConfiguredAt + 60 || burnAdapter.codehash != burnAdapterCodeHash) revert BurnUnavailable(); if (amount == 0 || amount > burnReserve || minimumTokens == 0 || deadline < block.timestamp || deadline > block.timestamp + 2 minutes) revert InvalidQuote(); IMuchBurnToken token = IMuchBurnToken(burnToken); uint256 beforeBalance = token.balanceOf(address(this)); burnReserve -= amount; uint256 reported = IMuchBuyback(burnAdapter).buy{value:amount}(minimumTokens, deadline); uint256 bought = token.balanceOf(address(this)) - beforeBalance; if (bought < minimumTokens || bought == 0 || reported != bought) revert BurnVerificationFailed(); uint256 supply = token.totalSupply(); token.burn(bought); if (token.balanceOf(address(this)) != beforeBalance || token.totalSupply() != supply - bought) revert BurnVerificationFailed(); totalBurned += bought; emit SupplyBurned(++burnCount, amount, bought, supply - bought); } function startNextRound() external nonReentrant { State state = rounds[currentRound].state; if (paused || (state != State.Resolved && state != State.Refunding)) revert InvalidState(); if (state == State.Refunding && refundCursor[currentRound] != players[currentRound].length) revert InvalidState(); currentRound++; rounds[currentRound].stateHash = EMPTY_STATE; rounds[currentRound].deadline = uint64(block.timestamp + ROUND_TIMEOUT); emit RoundOpened(currentRound); } }