// SPDX-License-Identifier: MIT pragma solidity 0.8.30; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; /// @dev The engine MUST authenticate msg.sender as its bound escrow, conserve /// game balances, and resolve dice, exit timing, and stalled-game recovery. interface INysaGameEngine { function startGame(uint256 gameId, address[] calldata players, uint256 stake) external; function exitGame(uint256 gameId, address player) external returns (uint256 payout); } /// @notice Non-upgradeable custody for 3–4 player, 5 NYSA tables. /// @dev This is escrow infrastructure, NOT a Stones & Bones rules engine. /// No owner, sweep, host payout override, or changeable engine exists. contract NysaGameEscrow is ReentrancyGuard { using SafeERC20 for IERC20; uint256 public constant STAKE = 5_000; // 5 NYSA, 3 decimals uint256 public constant MIN_PLAYERS = 3; uint256 public constant MAX_PLAYERS = 4; IERC20 public immutable token; INysaGameEngine public immutable engine; uint256 public nextGameId = 1; uint256 public totalLiability; enum Phase { Missing, Lobby, Playing, Closed, Cancelled } struct Game { address creator; uint64 fundingDeadline; Phase phase; uint256 remaining; uint256 activePlayers; address[] players; } mapping(uint256 => Game) private games; mapping(uint256 => mapping(address => bool)) public joined; mapping(uint256 => mapping(address => bool)) public ready; mapping(uint256 => mapping(address => bool)) public exited; mapping(address => uint256) public claimable; event GameCreated(uint256 indexed gameId, address indexed creator, uint64 deadline); event Staked(uint256 indexed gameId, address indexed player, uint256 amount); event Ready(uint256 indexed gameId, address indexed player, bool value); event LobbyLeft(uint256 indexed gameId, address indexed player); event Started(uint256 indexed gameId, uint256 pot); event ExitAllocated(uint256 indexed gameId, address indexed player, uint256 payout); event Cancelled(uint256 indexed gameId); event Withdrawn(address indexed player, uint256 amount); constructor(address token_, address engine_) { require(token_.code.length > 0 && engine_.code.length > 0, "contracts required"); require(IERC20Metadata(token_).decimals() == 3, "NYSA decimals required"); token = IERC20(token_); engine = INysaGameEngine(engine_); } function game(uint256 id) external view returns (address creator, uint64 deadline, Phase phase, uint256 remaining, uint256 activePlayers, address[] memory players) { Game storage g = games[id]; return (g.creator, g.fundingDeadline, g.phase, g.remaining, g.activePlayers, g.players); } function createGame(uint64 fundingDeadline) external returns (uint256 id) { require(fundingDeadline > block.timestamp && fundingDeadline <= block.timestamp + 7 days, "deadline 1s to 7d"); id = nextGameId++; Game storage g = games[id]; g.creator = msg.sender; g.fundingDeadline = fundingDeadline; g.phase = Phase.Lobby; emit GameCreated(id, msg.sender, fundingDeadline); } function join(uint256 id) external nonReentrant { Game storage g = games[id]; require(g.phase == Phase.Lobby && block.timestamp < g.fundingDeadline, "lobby not open"); require(!joined[id][msg.sender] && g.players.length < MAX_PLAYERS, "already joined or full"); // A roster change clears readiness so a newcomer cannot lock earlier consent. for (uint256 i; i < g.players.length; ++i) ready[id][g.players[i]] = false; joined[id][msg.sender] = true; g.players.push(msg.sender); g.remaining += STAKE; totalLiability += STAKE; uint256 beforeBalance = token.balanceOf(address(this)); token.safeTransferFrom(msg.sender, address(this), STAKE); require(token.balanceOf(address(this)) == beforeBalance + STAKE, "exact transfer required"); emit Staked(id, msg.sender, STAKE); } function setReady(uint256 id, bool value) external { Game storage g = games[id]; require(g.phase == Phase.Lobby && block.timestamp < g.fundingDeadline && joined[id][msg.sender], "not in open lobby"); ready[id][msg.sender] = value; emit Ready(id, msg.sender, value); } function leaveLobby(uint256 id) external nonReentrant { Game storage g = games[id]; require(g.phase == Phase.Lobby && joined[id][msg.sender], "not in lobby"); joined[id][msg.sender] = false; ready[id][msg.sender] = false; for (uint256 i; i < g.players.length; ++i) { if (g.players[i] == msg.sender) { g.players[i] = g.players[g.players.length - 1]; g.players.pop(); break; } } for (uint256 i; i < g.players.length; ++i) ready[id][g.players[i]] = false; g.remaining -= STAKE; claimable[msg.sender] += STAKE; emit LobbyLeft(id, msg.sender); } function cancelExpiredLobby(uint256 id) external nonReentrant { Game storage g = games[id]; require(g.phase == Phase.Lobby && block.timestamp >= g.fundingDeadline, "not expired lobby"); g.phase = Phase.Cancelled; g.remaining = 0; for (uint256 i; i < g.players.length; ++i) claimable[g.players[i]] += STAKE; emit Cancelled(id); } function start(uint256 id) external nonReentrant { Game storage g = games[id]; require(g.phase == Phase.Lobby && block.timestamp < g.fundingDeadline && joined[id][msg.sender], "not in open lobby"); require(g.players.length >= MIN_PLAYERS, "need 3 players"); for (uint256 i; i < g.players.length; ++i) require(ready[id][g.players[i]], "all must be ready"); g.phase = Phase.Playing; g.activePlayers = g.players.length; engine.startGame(id, g.players, STAKE); emit Started(id, g.remaining); } /// @notice The engine, not the caller or host, calculates the exit amount. /// A game participant can exit once. Others may continue playing. function exit(uint256 id) external nonReentrant { Game storage g = games[id]; require(g.phase == Phase.Playing && joined[id][msg.sender] && !exited[id][msg.sender], "not active player"); exited[id][msg.sender] = true; g.activePlayers--; uint256 amount = engine.exitGame(id, msg.sender); require(amount <= g.remaining, "payout exceeds pot"); g.remaining -= amount; claimable[msg.sender] += amount; if (g.activePlayers == 0) { require(g.remaining == 0, "engine must settle full pot"); g.phase = Phase.Closed; } emit ExitAllocated(id, msg.sender, amount); } /// @notice Pull only your own allocated payouts and lobby refunds. function withdraw() external nonReentrant { uint256 amount = claimable[msg.sender]; require(amount > 0, "nothing claimable"); claimable[msg.sender] = 0; totalLiability -= amount; token.safeTransfer(msg.sender, amount); require(token.balanceOf(address(this)) >= totalLiability, "escrow insolvent"); emit Withdrawn(msg.sender, amount); } }