>
Docs

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.

ChessStakeEscrowV2
Deployed on Polygon Mainnet
0x3231b866a0a585e3f074e9b92c862407214D7d66
20%
Protocol Fee
80%
To Winner
24h
Resolution Timeout
1h
Join Window

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 Architecture
Player 1
Creates game & deposits USDC
ChessStakeEscrowV2
Holds funds & resolves games
Player 2
Joins game & matches stake
Distribution
80% winner, 20% protocol

Contract Constants

These immutable values define the contract's core parameters and cannot be changed after deployment.

PROTOCOL_FEE_BPS
2000
20% in basis points
WINNER_SHARE_BPS
8000
80% in basis points
MIN_STAKE
1000
0.001 USDC (6 decimals)
RESOLUTION_TIMEOUT
24h
Game resolution window
JOIN_TIMEOUT
1h
Time for opponent to join

Game States & Results

The contract uses two enumerations to track game status and results. Understanding these is key to interacting with the contract.

GameStatus
0
None
Game doesn't exist
1
WaitingForP2
Waiting for player 2 to join
2
Active
Game in progress
3
Resolved
Game finished, funds distributed
4
Cancelled
Game cancelled before start
5
Refunded
Refunded due to timeout
GameResult
0
None
No result yet
1
Player1Wins
Game creator wins
2
Player2Wins
Joining player wins
3
Draw
Game ends in a draw

Functions

The contract exposes the following functions for interacting with the escrow system.

createGame
external nonReentrant

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.

Parameters
uint256
_stake - Amount of USDC to stake (must be >= MIN_STAKE)
Returns
uint256 gameId - The unique ID of the created game
joinGame
external nonReentrant

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).

Parameters
uint256
_gameId - ID of the game to join
cancelGame
external nonReentrant

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.

Parameters
uint256
_gameId - ID of the game to cancel
cancelExpiredGame
external nonReentrant

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.

Parameters
uint256
_gameId - ID of the expired game
submitResult
external nonReentrant

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.

Parameters
uint256
_gameId - ID of the game
GameResult
_result - The result being claimed (1, 2, or 3)
bytes32
_gameHash - Hash of game data (PGN) for verification
claimTimeoutRefund
external nonReentrant

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.

Parameters
uint256
_gameId - ID of the game to refund

View Functions

getGame
view

Returns the complete Game struct for a given game ID. Includes all game information: players, stake, status, result, timestamps, and signature states.

Returns
Game memory - Complete game data structure
getActiveGame
view

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.

Returns
uint256 - Active game ID or 0
calculatePayouts
view

Calculates the payout amounts for a given stake. Useful for showing users expected winnings before creating/joining a game.

Returns
(uint256 winnerPayout, uint256 protocolFee) - Amounts in USDC units

Events

The contract emits these events for off-chain indexing and transaction tracking.

GameCreated
Emitted when a new game is created
uint256 gameId indexed
address player1 indexed
uint256 stake
GameJoined
Emitted when Player 2 joins a game
uint256 gameId indexed
address player2 indexed
ResultSubmitted
Emitted when a player submits a result
uint256 gameId indexed
address player
GameResult claimedResult
GameResolved
Emitted when a game is resolved and funds distributed
uint256 gameId indexed
GameResult result
address winner
uint256 winnerPayout
GameCancelled
Emitted when a game is cancelled
uint256 gameId indexed
address cancelledBy
string reason
GameRefunded
Emitted when both players are refunded
uint256 gameId indexed
uint256 refundAmount

Security Features

The contract implements multiple security patterns to protect user funds and ensure fair game resolution.

ReentrancyGuard
All state-changing functions use OpenZeppelin's ReentrancyGuard to prevent reentrancy attacks.
SafeERC20
Uses OpenZeppelin's SafeERC20 library for all token transfers, handling edge cases in ERC20 implementations.
Double Signature
Game resolution requires both players to agree on the result, preventing unilateral fund withdrawals.
Timeout Protection
Built-in timeouts ensure funds are never permanently locked, even if players become unresponsive.
Single Active Game
Each address can only participate in one active game at a time, preventing exploit scenarios.
Immutable Constants
Core parameters like fee percentages are hardcoded constants, cannot be changed after deployment.

Full Contract Code

The complete Solidity source code for ChessStakeEscrowV2. This is the exact code deployed on Polygon mainnet.

View Full Contract Code (ChessStakeEscrowV2.sol)
ChessStakeEscrowV2.sol
// 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: