Smart Contract
Complete technical reference for ChessStakeEscrowV2 - our non-custodial P2P escrow system deployed on Polygon. Learn how the contract works, its functions, and security features.
Overview
ChessStakeEscrowV2 is a non-custodial peer-to-peer escrow smart contract designed specifically for chess matches. It holds USDC stakes during games and automatically distributes funds based on game results using server arbiter signature verification.
Contract Constants
These immutable values define the contract's core parameters and cannot be changed after deployment.
Game States & Results
The contract uses two enumerations to track game status and results. Understanding these is key to interacting with the contract.
Functions
The contract exposes the following functions for interacting with the escrow system.
Creates a new game and deposits the stake. The caller becomes Player 1 (game creator). Requires USDC approval before calling. The game enters WaitingForP2 status and waits for an opponent to join.
Joins an existing game by depositing a matching stake. The caller becomes Player 2. The game transitions to Active status. Must be called within the JOIN_TIMEOUT period (1 hour from creation).
Cancels a game that hasn't started yet. Only the game creator (Player 1) can call this. The stake is fully refunded. Only works when status is WaitingForP2.
Cancels a game that has exceeded the join timeout without an opponent. Anyone can call this function after the timeout. Refunds the creator's stake automatically.
Submits a game result claim. Both players must call this with matching results (double-signature). When both players agree on the result, funds are automatically distributed. If players disagree, the game stays active until timeout.
Claims a refund after the resolution timeout (24 hours). Use when players cannot agree on a result. Both players receive their original stakes back. The game status changes to Refunded.
View Functions
Returns the complete Game struct for a given game ID. Includes all game information: players, stake, status, result, timestamps, and signature states.
Returns the active game ID for a player address. Returns 0 if the player has no active game. A player can only have one active game at a time.
Calculates the payout amounts for a given stake. Useful for showing users expected winnings before creating/joining a game.
Events
The contract emits these events for off-chain indexing and transaction tracking.
Security Features
The contract implements multiple security patterns to protect user funds and ensure fair game resolution.
Full Contract Code
The complete Solidity source code for ChessStakeEscrowV2. This is the exact code deployed on Polygon mainnet.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; /** * @title ChessStakeEscrowV2 * @notice Non-custodial P2P escrow for chess matches using USDC * @dev Fixed version with server arbiter to prevent losers from blocking payouts */ contract ChessStakeEscrowV2 is ReentrancyGuard { using SafeERC20 for IERC20; using ECDSA for bytes32; using MessageHashUtils for bytes32; // ============================================ // Constants // ============================================ uint256 public constant PROTOCOL_FEE_BPS = 2000; // 20% uint256 public constant WINNER_SHARE_BPS = 8000; // 80% uint256 public constant BPS_DENOMINATOR = 10000; uint256 public constant MIN_STAKE = 1000; // 0.001 USDC uint256 public constant RESOLUTION_TIMEOUT = 24 hours; uint256 public constant JOIN_TIMEOUT = 1 hours; // ============================================ // State Variables // ============================================ IERC20 public immutable usdc; address public immutable protocolWallet; address public arbiter; uint256 public gameCounter; // ============================================ // Enums & Structs // ============================================ enum GameStatus { None, // 0: Game doesn't exist WaitingForP2, // 1: Waiting for player 2 Active, // 2: Game in progress Resolved, // 3: Funds distributed Cancelled, // 4: Cancelled before start Refunded // 5: Timeout refund } enum GameResult { None, // 0: No result Player1Wins, // 1: Creator wins Player2Wins, // 2: Joiner wins Draw // 3: Draw } struct Game { uint256 id; address player1; address player2; uint256 stake; GameStatus status; GameResult result; uint256 createdAt; uint256 startedAt; uint256 resolvedAt; bytes32 gameHash; } mapping(uint256 => Game) public games; mapping(address => uint256) public activeGame; // ============================================ // Events // ============================================ event GameCreated(uint256 indexed gameId, address indexed player1, uint256 stake, uint256 createdAt); event GameJoined(uint256 indexed gameId, address indexed player2, uint256 startedAt); event GameCancelled(uint256 indexed gameId, address indexed cancelledBy, string reason); event GameResolved(uint256 indexed gameId, GameResult result, address winner, uint256 winnerPayout, uint256 protocolFee); event GameRefunded(uint256 indexed gameId, uint256 refundAmount); event ArbiterChanged(address indexed oldArbiter, address indexed newArbiter); // ============================================ // Errors // ============================================ error InvalidStake(); error PlayerAlreadyInGame(); error GameNotFound(); error NotAuthorized(); error InvalidGameStatus(); error CannotJoinOwnGame(); error JoinTimeoutExpired(); error ResolutionTimeoutNotReached(); error InvalidResult(); error InvalidSignature(); error ZeroAddress(); // ============================================ // Constructor // ============================================ constructor(address _usdc, address _protocolWallet, address _arbiter) { if (_usdc == address(0)) revert ZeroAddress(); if (_protocolWallet == address(0)) revert ZeroAddress(); if (_arbiter == address(0)) revert ZeroAddress(); usdc = IERC20(_usdc); protocolWallet = _protocolWallet; arbiter = _arbiter; } // ============================================ // Admin Functions // ============================================ function setArbiter(address _newArbiter) external { if (msg.sender != arbiter) revert NotAuthorized(); if (_newArbiter == address(0)) revert ZeroAddress(); address oldArbiter = arbiter; arbiter = _newArbiter; emit ArbiterChanged(oldArbiter, _newArbiter); } // ============================================ // External Functions // ============================================ function createGame(uint256 _stake) external nonReentrant returns (uint256 gameId) { if (_stake < MIN_STAKE) revert InvalidStake(); if (activeGame[msg.sender] != 0) revert PlayerAlreadyInGame(); usdc.safeTransferFrom(msg.sender, address(this), _stake); gameCounter++; gameId = gameCounter; games[gameId] = Game({ id: gameId, player1: msg.sender, player2: address(0), stake: _stake, status: GameStatus.WaitingForP2, result: GameResult.None, createdAt: block.timestamp, startedAt: 0, resolvedAt: 0, gameHash: bytes32(0) }); activeGame[msg.sender] = gameId; emit GameCreated(gameId, msg.sender, _stake, block.timestamp); } function joinGame(uint256 _gameId) external nonReentrant { Game storage game = games[_gameId]; if (game.id == 0) revert GameNotFound(); if (game.status != GameStatus.WaitingForP2) revert InvalidGameStatus(); if (activeGame[msg.sender] != 0 && activeGame[msg.sender] != _gameId) revert PlayerAlreadyInGame(); if (block.timestamp > game.createdAt + JOIN_TIMEOUT) revert JoinTimeoutExpired(); usdc.safeTransferFrom(msg.sender, address(this), game.stake); game.player2 = msg.sender; game.status = GameStatus.Active; game.startedAt = block.timestamp; activeGame[msg.sender] = _gameId; emit GameJoined(_gameId, msg.sender, block.timestamp); } function cancelGame(uint256 _gameId) external nonReentrant { Game storage game = games[_gameId]; if (game.id == 0) revert GameNotFound(); if (game.player1 != msg.sender) revert NotAuthorized(); if (game.status != GameStatus.WaitingForP2) revert InvalidGameStatus(); game.status = GameStatus.Cancelled; activeGame[msg.sender] = 0; usdc.safeTransfer(game.player1, game.stake); emit GameCancelled(_gameId, msg.sender, "Creator cancelled"); } function cancelExpiredGame(uint256 _gameId) external nonReentrant { Game storage game = games[_gameId]; if (game.id == 0) revert GameNotFound(); if (game.status != GameStatus.WaitingForP2) revert InvalidGameStatus(); if (block.timestamp <= game.createdAt + JOIN_TIMEOUT) revert JoinTimeoutExpired(); game.status = GameStatus.Cancelled; activeGame[game.player1] = 0; usdc.safeTransfer(game.player1, game.stake); emit GameCancelled(_gameId, msg.sender, "Join timeout expired"); } function resolveWithSignature( uint256 _gameId, GameResult _result, bytes32 _gameHash, bytes calldata _signature ) external nonReentrant { Game storage game = games[_gameId]; if (game.id == 0) revert GameNotFound(); if (game.status != GameStatus.Active) revert InvalidGameStatus(); if (_result == GameResult.None) revert InvalidResult(); bytes32 messageHash = keccak256(abi.encodePacked( _gameId, _result, _gameHash, address(this) )); bytes32 ethSignedHash = messageHash.toEthSignedMessageHash(); address signer = ethSignedHash.recover(_signature); if (signer != arbiter) revert InvalidSignature(); game.gameHash = _gameHash; _resolveGame(_gameId, _result); } function claimTimeoutRefund(uint256 _gameId) external nonReentrant { Game storage game = games[_gameId]; if (game.id == 0) revert GameNotFound(); if (game.status != GameStatus.Active) revert InvalidGameStatus(); if (block.timestamp <= game.startedAt + RESOLUTION_TIMEOUT) { revert ResolutionTimeoutNotReached(); } game.status = GameStatus.Refunded; game.resolvedAt = block.timestamp; activeGame[game.player1] = 0; activeGame[game.player2] = 0; usdc.safeTransfer(game.player1, game.stake); usdc.safeTransfer(game.player2, game.stake); emit GameRefunded(_gameId, game.stake); } // ============================================ // View Functions // ============================================ function getGame(uint256 _gameId) external view returns (Game memory) { return games[_gameId]; } function getActiveGame(address _player) external view returns (uint256) { return activeGame[_player]; } function calculatePayouts(uint256 _stake) public pure returns ( uint256 winnerPayout, uint256 protocolFee ) { uint256 totalPot = _stake * 2; protocolFee = (totalPot * PROTOCOL_FEE_BPS) / BPS_DENOMINATOR; winnerPayout = totalPot - protocolFee; } function verifySignature( uint256 _gameId, GameResult _result, bytes32 _gameHash, bytes calldata _signature ) external view returns (bool) { bytes32 messageHash = keccak256(abi.encodePacked( _gameId, _result, _gameHash, address(this) )); bytes32 ethSignedHash = messageHash.toEthSignedMessageHash(); address signer = ethSignedHash.recover(_signature); return signer == arbiter; } // ============================================ // Internal Functions // ============================================ function _resolveGame(uint256 _gameId, GameResult _result) internal { Game storage game = games[_gameId]; game.status = GameStatus.Resolved; game.result = _result; game.resolvedAt = block.timestamp; activeGame[game.player1] = 0; activeGame[game.player2] = 0; uint256 totalPot = game.stake * 2; uint256 protocolFee = (totalPot * PROTOCOL_FEE_BPS) / BPS_DENOMINATOR; if (_result == GameResult.Draw) { uint256 eachPlayerGets = (totalPot - protocolFee) / 2; usdc.safeTransfer(game.player1, eachPlayerGets); usdc.safeTransfer(game.player2, eachPlayerGets); usdc.safeTransfer(protocolWallet, protocolFee); emit GameResolved(_gameId, _result, address(0), eachPlayerGets, protocolFee); } else { uint256 winnerPayout = totalPot - protocolFee; address winner = _result == GameResult.Player1Wins ? game.player1 : game.player2; usdc.safeTransfer(winner, winnerPayout); usdc.safeTransfer(protocolWallet, protocolFee); emit GameResolved(_gameId, _result, winner, winnerPayout, protocolFee); } } }
Learn More
Continue exploring ChessStake: