Skip to main content
PulseScanner.io

Address

0x421c9a85ecbe63d2114bc8daaec236cbb76cd41b
Current Holdings
$0.00
TXs sent
not counted
First Active
2026-08-14
block 27,283,055
Last Active
33 days ago
block 27,287,680
Funded By
not identified

Net worth historyi

3 snapshots · to block 27,497,274coverage change 26 Augcoverage change 27 Augcoverage change 27 Augcoverage change 27 Augcoverage change 27 Aug
exact matchChessWagersolc 0.8.34+commit.80d5c536runtime exact · creation exact
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

/*

░█████╗░██╗░░██╗███████╗░██████╗░██████╗  ░██████╗░░█████╗░███╗░░░███╗███████╗
██╔══██╗██║░░██║██╔════╝██╔════╝██╔════╝  ██╔════╝░██╔══██╗████╗░████║██╔════╝
██║░░╚═╝███████║█████╗░░╚█████╗░╚█████╗░  ██║░░██╗░███████║██╔████╔██║█████╗░░
██║░░██╗██╔══██║██╔══╝░░░╚═══██╗░╚═══██╗  ██║░░╚██╗██╔══██║██║╚██╔╝██║██╔══╝░░
╚█████╔╝██║░░██║███████╗██████╔╝██████╔╝  ╚██████╔╝██║░░██║██║░╚═╝░██║███████╗
░╚════╝░╚═╝░░╚═╝╚══════╝╚═════╝░╚═════╝░  ░╚═════╝░╚═╝░░╚═╝╚═╝░░░░░╚═╝╚══════╝

*/

interface IChessLib {
    struct PackedBoard {
        uint8[64] squares;
        bool whiteToMove;
        uint8 castlingRights;
        uint8 enPassantSquare;
        uint16 halfmoveClock;
    }

    function hashBoard(PackedBoard memory b) external pure returns (bytes32);
    function validateBoard(PackedBoard memory b) external pure returns (bool);
    function applyMove(PackedBoard memory b, uint8 from, uint8 to, uint8 promotion) external pure returns (PackedBoard memory);
    function evaluateTerminal(PackedBoard memory b) external pure returns (uint8);
}

interface IERC20 {
    function balanceOf(address account) external view returns (uint256);
    function transfer(address to, uint256 amount) external returns (bool);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
}

library SafeERC20 {
    function safeTransfer(IERC20 token, address to, uint256 value) internal {
        (bool ok, bytes memory data) = address(token).call(
            abi.encodeWithSelector(token.transfer.selector, to, value)
        );
        require(ok && (data.length == 0 || abi.decode(data, (bool))), "TRANSFER_FAILED");
    }
    function safeTransferFrom(IERC20 token, address from, address to, uint256 value) internal {
        (bool ok, bytes memory data) = address(token).call(
            abi.encodeWithSelector(token.transferFrom.selector, from, to, value)
        );
        require(ok && (data.length == 0 || abi.decode(data, (bool))), "TRANSFER_FROM_FAILED");
    }
}

abstract contract ReentrancyGuard {
    uint256 private _status = 1;
    modifier nonReentrant() {
        require(_status != 2, "REENTRANT");
        _status = 2;
        _;
        _status = 1;
    }
}

abstract contract Ownable2Step {
    address private _owner;
    address private _pendingOwner;
    event OwnershipTransferred(address indexed prev, address indexed next);
    constructor(address initial) {
        require(initial != address(0), "ZERO");
        _owner = initial;
        emit OwnershipTransferred(address(0), initial);
    }
    modifier onlyOwner() {
        require(msg.sender == _owner, "NOT_OWNER");
        _;
    }
    function owner() public view returns (address) { return _owner; }
    function transferOwnership(address n) external onlyOwner { _pendingOwner = n; }
    function acceptOwnership() external {
        require(msg.sender == _pendingOwner, "NOT_PENDING");
        emit OwnershipTransferred(_owner, _pendingOwner);
        _owner = _pendingOwner;
        _pendingOwner = address(0);
    }
}

abstract contract EIP712 {
    bytes32 private immutable _CACHED_DOMAIN_SEPARATOR;
    uint256 private immutable _CACHED_CHAIN_ID;
    address private immutable _CACHED_THIS;
    bytes32 private immutable _HASHED_NAME;
    bytes32 private immutable _HASHED_VERSION;

    constructor(string memory name, string memory version) {
        _HASHED_NAME = keccak256(bytes(name));
        _HASHED_VERSION = keccak256(bytes(version));
        _CACHED_CHAIN_ID = block.chainid;
        _CACHED_THIS = address(this);
        _CACHED_DOMAIN_SEPARATOR = _buildDomainSeparator();
    }

    function _domainSeparatorV4() internal view returns (bytes32) {
        if (address(this) == _CACHED_THIS && block.chainid == _CACHED_CHAIN_ID) {
            return _CACHED_DOMAIN_SEPARATOR;
        }
        return _buildDomainSeparator();
    }

    function _buildDomainSeparator() private view returns (bytes32) {
        return keccak256(abi.encode(
            keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
            _HASHED_NAME, _HASHED_VERSION, block.chainid, address(this)
        ));
    }

    function _hashTypedDataV4(bytes32 structHash) internal view returns (bytes32) {
        return keccak256(abi.encodePacked("\x19\x01", _domainSeparatorV4(), structHash));
    }
}

contract ChessWager is ReentrancyGuard, Ownable2Step, EIP712 {
    using SafeERC20 for IERC20;

    // Hardcoded library address
    IChessLib constant CHESS = IChessLib(0xfFDc6Fb47DA0A0C28db802Fa07568d5862314d18);

    address public constant MAGIC  = 0xD63b9d8D6E38cb7fBfDcEEde3cE92F97f5Aea7Ac;
    address public constant POISON = 0xB8c8761FeD2AaD5C0A75561bC604531a42c452E6;

    uint256 public constant PLATFORM_FEE_BPS = 500;      // 5%
    uint256 public constant BPS_DENOMINATOR  = 10_000;
    uint256 public constant DISPUTE_PERIOD   = 1 hours;
    uint256 public constant CHALLENGE_EXPIRY = 24 hours;

    // Fee gaat hier naartoe i.p.v. address(0)
    // Standaard: dead address (tokens zijn "burned")
    address public feeRecipient = 0x000000000000000000000000000000000000dEaD;

    enum GameStatus { Waiting, Active, Disputed, Finished, Cancelled }
    enum ResultType { None, Checkmate, Resignation, Timeout, Draw, AgreedDraw }

    struct Game {
        address playerWhite;
        address playerBlack;
        address wagerToken;
        uint256 intendedWager;      // wat de gebruiker probeerde te storten
        uint256 whiteDeposited;     // wat er écht binnenkwam van white
        uint256 blackDeposited;     // wat er écht binnenkwam van black
        uint256 totalPot;           // whiteDeposited + blackDeposited
        uint64  createdAt;
        uint64  startedAt;
        uint64  challengeExpiresAt;
        uint32  timeControlSeconds;
        GameStatus status;
        address winner;
        ResultType resultType;
        bool    prizeClaimed;
        uint256 highestSequence;
        uint64  disputeDeadline;
        address provisionalWinner;
        ResultType provisionalResult;
        bytes32 lastBoardHash;
        address lastToMove;
    }

    struct GameState {
        uint256 gameId;
        uint256 sequence;
        bytes32 boardHash;
        address currentPlayer;
        uint256 whiteTimeRemaining;
        uint256 blackTimeRemaining;
        uint256 lastActionTimestamp;
        bytes32 previousStateHash;
    }

    struct SignedMove {
        uint256 gameId;
        uint256 sequence;
        bytes32 previousStateHash;
        bytes32 newBoardHash;
        uint8   from;
        uint8   to;
        uint8   promotion;
    }

    struct SessionAuthorization {
        uint256 gameId;
        address player;
        address sessionKey;
        uint64  expiresAt;
        uint256 nonce;
    }

    struct UnilateralTerminalClaim {
        uint256 gameId;
        GameState lastMutualState;
        bytes sigWhite;
        bytes sigBlack;
        IChessLib.PackedBoard boardAtMutualState;
        SignedMove finalMove;
        bytes finalMoveSignature;
    }

    struct GameResult {
        uint256 gameId;
        address winner;
        ResultType resultType;
        bytes32 finalBoardHash;
        uint256 finalSequence;
    }

    struct Resignation {
        uint256 gameId;
        address player;
        uint256 nonce;
    }

    uint256 public nextGameId = 1;
    mapping(uint256 => Game) public games;
    mapping(address => uint256) public totalFeesCollected;

    mapping(uint256 => mapping(address => address)) public sessionKeyOf;
    mapping(uint256 => mapping(address => uint64))  public sessionExpiryOf;
    mapping(uint256 => mapping(address => uint256)) public sessionNonceOf;

    event GameCreated(uint256 indexed gameId, address indexed creator, address token, uint256 intendedAmount, uint256 actualReceived, address opponent, uint32 timeControl, uint64 expiresAt);
    event GameAccepted(uint256 indexed gameId, address indexed acceptor, uint256 actualReceived, uint64 startedAt);
    event GameCancelled(uint256 indexed gameId);
    event GameSettled(uint256 indexed gameId, address winner, ResultType resultType, uint256 payout, uint256 fee);
    event DisputeOpened(uint256 indexed gameId, address submitter, uint256 sequence, uint64 deadline);
    event DisputeChallenged(uint256 indexed gameId, address challenger, uint256 newSequence);
    event DisputeFinalized(uint256 indexed gameId, address winner, ResultType resultType);
    event TimeoutClaimed(uint256 indexed gameId, address claimant, uint256 sequence);
    event PlayerResigned(uint256 indexed gameId, address player);
    event TerminalClaimed(uint256 indexed gameId, address claimant, address provisionalWinner);
    event FeeCollected(address token, uint256 amount, address recipient);
    event FeeRecipientUpdated(address indexed oldRecipient, address indexed newRecipient);
    event SessionKeyAuthorized(uint256 indexed gameId, address indexed player, address sessionKey, uint64 expiresAt);
    event MoveChallenged(uint256 indexed gameId, uint256 sequence, address mover, bytes32 newBoardHash);

    error UnsupportedToken();
    error InvalidAmount();
    error GameNotFound();
    error InvalidStatus();
    error NotPlayer();
    error NotCreator();
    error ChallengeExpired();
    error OpponentMismatch();
    error InvalidSignature();
    error SequenceTooLow();
    error TimeoutNotReached();
    error DisputeWindowClosed();
    error DisputeStillOpen();
    error NoDispute();
    error AlreadyFinished();
    error ZeroAddress();
    error CannotRescueWagerToken();
    error BoardHashMismatch();
    error MoveHashMismatch();
    error NotTerminal();
    error WrongMover();
    error BadSequence();
    error InvalidPreviousStateHash();
    error StateBoardMismatch();
    error InvalidPromotion();
    error StaleSettlement();
    error InvalidBoard();
    error SessionExpired();
    error BadSessionAuth();
    error ZeroReceived();

    bytes32 private constant GAME_STATE_TYPEHASH = keccak256(
        "GameState(uint256 gameId,uint256 sequence,bytes32 boardHash,address currentPlayer,uint256 whiteTimeRemaining,uint256 blackTimeRemaining,uint256 lastActionTimestamp,bytes32 previousStateHash)"
    );
    bytes32 private constant SIGNED_MOVE_TYPEHASH = keccak256(
        "SignedMove(uint256 gameId,uint256 sequence,bytes32 previousStateHash,bytes32 newBoardHash,uint8 from,uint8 to,uint8 promotion)"
    );
    bytes32 private constant GAME_RESULT_TYPEHASH = keccak256(
        "GameResult(uint256 gameId,address winner,uint8 resultType,bytes32 finalBoardHash,uint256 finalSequence)"
    );
    bytes32 private constant RESIGNATION_TYPEHASH = keccak256(
        "Resignation(uint256 gameId,address player,uint256 nonce)"
    );
    bytes32 private constant SESSION_AUTH_TYPEHASH = keccak256(
        "SessionAuthorization(uint256 gameId,address player,address sessionKey,uint64 expiresAt,uint256 nonce)"
    );

    constructor(address initialOwner) Ownable2Step(initialOwner) EIP712("ChessWagerPulse", "6") {}

    function isSupportedToken(address token) public pure returns (bool) {
        return token == MAGIC || token == POISON;
    }

    function setFeeRecipient(address newRecipient) external onlyOwner {
        if (newRecipient == address(0)) revert ZeroAddress();
        emit FeeRecipientUpdated(feeRecipient, newRecipient);
        feeRecipient = newRecipient;
    }

    function _isAuthorizedSigner(uint256 gameId, address player, address recovered) internal view returns (bool) {
        if (recovered == player) return true;
        if (recovered == address(0)) return false;
        if (sessionKeyOf[gameId][player] == recovered && block.timestamp <= sessionExpiryOf[gameId][player]) {
            return true;
        }
        return false;
    }

    /// @dev Pull tokens and return the actual amount that arrived (handles fee-on-transfer)
    function _pullTokens(address token, address from, uint256 amount) internal returns (uint256 received) {
        uint256 balBefore = IERC20(token).balanceOf(address(this));
        IERC20(token).safeTransferFrom(from, address(this), amount);
        received = IERC20(token).balanceOf(address(this)) - balBefore;
        if (received == 0) revert ZeroReceived();
    }

    function authorizeSessionKey(
        SessionAuthorization calldata auth,
        bytes calldata signature
    ) external {
        if (auth.player != msg.sender) revert BadSessionAuth();
        if (auth.sessionKey == address(0)) revert ZeroAddress();
        if (auth.expiresAt <= block.timestamp) revert SessionExpired();

        Game storage g = games[auth.gameId];
        if (g.playerWhite == address(0)) revert GameNotFound();
        if (auth.player != g.playerWhite && auth.player != g.playerBlack) revert NotPlayer();
        if (auth.nonce != sessionNonceOf[auth.gameId][auth.player]) revert BadSessionAuth();

        bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(
            SESSION_AUTH_TYPEHASH,
            auth.gameId,
            auth.player,
            auth.sessionKey,
            auth.expiresAt,
            auth.nonce
        )));
        if (_recover(digest, signature) != auth.player) revert InvalidSignature();

        sessionKeyOf[auth.gameId][auth.player] = auth.sessionKey;
        sessionExpiryOf[auth.gameId][auth.player] = auth.expiresAt;
        sessionNonceOf[auth.gameId][auth.player] = auth.nonce + 1;

        emit SessionKeyAuthorized(auth.gameId, auth.player, auth.sessionKey, auth.expiresAt);
    }

    function createGame(
        address wagerToken,
        uint256 wagerAmount,
        address opponent,
        uint32 timeControlSeconds
    ) external nonReentrant returns (uint256 gameId) {
        if (!isSupportedToken(wagerToken)) revert UnsupportedToken();
        if (wagerAmount == 0) revert InvalidAmount();
        if (timeControlSeconds < 5 || timeControlSeconds > 3600) revert InvalidAmount();

        // Meet wat er écht binnenkomt
        uint256 actualReceived = _pullTokens(wagerToken, msg.sender, wagerAmount);

        gameId = nextGameId++;
        Game storage g = games[gameId];
        g.playerWhite = msg.sender;
        g.playerBlack = opponent;
        g.wagerToken = wagerToken;
        g.intendedWager = wagerAmount;
        g.whiteDeposited = actualReceived;
        g.totalPot = actualReceived;
        g.createdAt = uint64(block.timestamp);
        g.challengeExpiresAt = uint64(block.timestamp + CHALLENGE_EXPIRY);
        g.timeControlSeconds = timeControlSeconds;
        g.status = GameStatus.Waiting;

        emit GameCreated(gameId, msg.sender, wagerToken, wagerAmount, actualReceived, opponent, timeControlSeconds, g.challengeExpiresAt);
    }

    function acceptGame(uint256 gameId) external nonReentrant {
        Game storage g = games[gameId];
        if (g.playerWhite == address(0)) revert GameNotFound();
        if (g.status != GameStatus.Waiting) revert InvalidStatus();
        if (block.timestamp > g.challengeExpiresAt) revert ChallengeExpired();
        if (g.playerBlack != address(0) && g.playerBlack != msg.sender) revert OpponentMismatch();
        if (msg.sender == g.playerWhite) revert InvalidStatus();

        // Opponent stuurt hetzelfde intended bedrag, we meten wat er écht binnenkomt
        uint256 actualReceived = _pullTokens(g.wagerToken, msg.sender, g.intendedWager);

        g.playerBlack = msg.sender;
        g.blackDeposited = actualReceived;
        g.totalPot = g.whiteDeposited + actualReceived;
        g.startedAt = uint64(block.timestamp);
        g.status = GameStatus.Active;

        emit GameAccepted(gameId, msg.sender, actualReceived, g.startedAt);
    }

    function cancelGame(uint256 gameId) external nonReentrant {
        Game storage g = games[gameId];
        if (g.playerWhite == address(0)) revert GameNotFound();
        if (g.status != GameStatus.Waiting) revert InvalidStatus();
        if (msg.sender != g.playerWhite) revert NotCreator();

        g.status = GameStatus.Cancelled;

        // Refund alleen wat er écht is gestort
        uint256 refund = g.whiteDeposited;
        g.whiteDeposited = 0;
        g.totalPot = 0;

        if (refund > 0) {
            IERC20(g.wagerToken).safeTransfer(g.playerWhite, refund);
        }

        emit GameCancelled(gameId);
    }

    function settleGame(
        uint256 gameId,
        GameResult calldata result,
        bytes calldata sigA,
        bytes calldata sigB
    ) external nonReentrant {
        Game storage g = games[gameId];
        if (g.status != GameStatus.Active && g.status != GameStatus.Disputed) revert InvalidStatus();
        if (result.gameId != gameId) revert GameNotFound();

        bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(
            GAME_RESULT_TYPEHASH,
            result.gameId, result.winner, uint8(result.resultType),
            result.finalBoardHash, result.finalSequence
        )));

        address a = _recover(digest, sigA);
        address b = _recover(digest, sigB);
        bool ok = (a == g.playerWhite && b == g.playerBlack) || (a == g.playerBlack && b == g.playerWhite);
        if (!ok) revert InvalidSignature();
        if (result.winner != address(0) && result.winner != g.playerWhite && result.winner != g.playerBlack) {
            revert InvalidSignature();
        }
        if (result.finalSequence < g.highestSequence) revert StaleSettlement();
        _finalize(gameId, result.winner, result.resultType);
    }

    function resignWithSignature(
        uint256 gameId,
        Resignation calldata res,
        bytes calldata signature
    ) external nonReentrant {
        Game storage g = games[gameId];
        if (g.status != GameStatus.Active && g.status != GameStatus.Disputed) revert InvalidStatus();
        if (res.gameId != gameId) revert GameNotFound();

        bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(
            RESIGNATION_TYPEHASH, res.gameId, res.player, res.nonce
        )));
        address signer = _recover(digest, signature);
        if (!_isAuthorizedSigner(gameId, res.player, signer)) revert InvalidSignature();
        if (res.player != g.playerWhite && res.player != g.playerBlack) revert NotPlayer();

        address winner = res.player == g.playerWhite ? g.playerBlack : g.playerWhite;
        emit PlayerResigned(gameId, res.player);
        _finalize(gameId, winner, ResultType.Resignation);
    }

    function claimTimeout(
        uint256 gameId,
        GameState calldata state,
        bytes calldata sigWhite,
        bytes calldata sigBlack
    ) external nonReentrant {
        Game storage g = games[gameId];
        if (g.status != GameStatus.Active && g.status != GameStatus.Disputed) revert InvalidStatus();
        if (state.gameId != gameId) revert GameNotFound();

        _verifyGameStateSignatures(g, state, sigWhite, sigBlack);

        if (g.status == GameStatus.Active) {
            if (state.sequence <= g.highestSequence) revert SequenceTooLow();
        } else {
            if (state.sequence < g.highestSequence) revert SequenceTooLow();
        }

        address timedOut = state.currentPlayer;
        if (timedOut != g.playerWhite && timedOut != g.playerBlack) revert NotPlayer();
        address claimant = timedOut == g.playerWhite ? g.playerBlack : g.playerWhite;
        if (msg.sender != claimant) revert NotPlayer();

        uint256 remaining = timedOut == g.playerWhite ? state.whiteTimeRemaining : state.blackTimeRemaining;
        if (g.timeControlSeconds > 0 && remaining > g.timeControlSeconds) revert TimeoutNotReached();
        if (state.lastActionTimestamp > block.timestamp + 120) revert TimeoutNotReached();
        if (block.timestamp < state.lastActionTimestamp + remaining) revert TimeoutNotReached();

        g.status = GameStatus.Disputed;
        g.highestSequence = state.sequence;
        g.disputeDeadline = uint64(block.timestamp + DISPUTE_PERIOD);
        g.provisionalWinner = claimant;
        g.provisionalResult = ResultType.Timeout;
        g.lastBoardHash = state.boardHash;

        emit TimeoutClaimed(gameId, msg.sender, state.sequence);
        emit DisputeOpened(gameId, msg.sender, state.sequence, g.disputeDeadline);
    }

    function challengeDispute(
        uint256 gameId,
        GameState calldata newerState,
        bytes calldata sigWhite,
        bytes calldata sigBlack
    ) external nonReentrant {
        Game storage g = games[gameId];
        if (g.status != GameStatus.Disputed) revert NoDispute();
        if (block.timestamp > g.disputeDeadline) revert DisputeWindowClosed();
        if (newerState.gameId != gameId) revert GameNotFound();
        if (newerState.sequence <= g.highestSequence) revert SequenceTooLow();

        _verifyGameStateSignatures(g, newerState, sigWhite, sigBlack);

        g.highestSequence = newerState.sequence;
        g.disputeDeadline = uint64(block.timestamp + DISPUTE_PERIOD);
        g.lastBoardHash = newerState.boardHash;

        address timedOut = newerState.currentPlayer;
        uint256 remaining = timedOut == g.playerWhite ? newerState.whiteTimeRemaining : newerState.blackTimeRemaining;
        bool timed = block.timestamp >= newerState.lastActionTimestamp + remaining;

        if (timed && (timedOut == g.playerWhite || timedOut == g.playerBlack)) {
            g.provisionalWinner = timedOut == g.playerWhite ? g.playerBlack : g.playerWhite;
            g.provisionalResult = ResultType.Timeout;
        } else {
            g.provisionalWinner = address(0);
            g.provisionalResult = ResultType.None;
        }

        emit DisputeChallenged(gameId, msg.sender, newerState.sequence);
        emit DisputeOpened(gameId, msg.sender, newerState.sequence, g.disputeDeadline);
    }

    function finalizeDispute(uint256 gameId) external nonReentrant {
        Game storage g = games[gameId];
        if (g.status != GameStatus.Disputed) revert NoDispute();
        if (block.timestamp <= g.disputeDeadline) revert DisputeStillOpen();

        if (g.provisionalResult == ResultType.None) {
            g.status = GameStatus.Active;
            g.disputeDeadline = 0;
            return;
        }

        _finalize(gameId, g.provisionalWinner, g.provisionalResult);
        emit DisputeFinalized(gameId, g.winner, g.resultType);
    }

    function challengeWithMove(
        uint256 gameId,
        GameState calldata baseState,
        bytes calldata sigWhite,
        bytes calldata sigBlack,
        IChessLib.PackedBoard calldata board,
        SignedMove calldata move,
        bytes calldata moveSig
    ) external nonReentrant {
        Game storage g = games[gameId];
        if (g.playerWhite == address(0)) revert GameNotFound();
        if (g.status != GameStatus.Active && g.status != GameStatus.Disputed) revert InvalidStatus();

        _verifyGameStateSignatures(g, baseState, sigWhite, sigBlack);
        if (baseState.gameId != gameId) revert GameNotFound();
        if (baseState.sequence < g.highestSequence) revert SequenceTooLow();

        if (!CHESS.validateBoard(board)) revert InvalidBoard();
        if (CHESS.hashBoard(board) != baseState.boardHash) revert BoardHashMismatch();

        if (move.gameId != gameId) revert GameNotFound();
        if (move.sequence != baseState.sequence + 1) revert BadSequence();

        bytes32 baseHash = keccak256(abi.encode(
            GAME_STATE_TYPEHASH,
            baseState.gameId,
            baseState.sequence,
            baseState.boardHash,
            baseState.currentPlayer,
            baseState.whiteTimeRemaining,
            baseState.blackTimeRemaining,
            baseState.lastActionTimestamp,
            baseState.previousStateHash
        ));
        if (move.previousStateHash != baseHash) revert InvalidPreviousStateHash();

        address expectedPlayer = board.whiteToMove ? g.playerWhite : g.playerBlack;
        if (baseState.currentPlayer != expectedPlayer) revert StateBoardMismatch();

        address mover = baseState.currentPlayer;
        bytes32 moveDigest = _hashTypedDataV4(keccak256(abi.encode(
            SIGNED_MOVE_TYPEHASH,
            move.gameId,
            move.sequence,
            move.previousStateHash,
            move.newBoardHash,
            move.from,
            move.to,
            move.promotion
        )));
        address recovered = _recover(moveDigest, moveSig);
        if (!_isAuthorizedSigner(gameId, mover, recovered)) revert WrongMover();

        uint8 promo = move.promotion;
        if (promo != 0) {
            bool validPromo =
                promo == 2 || promo == 3 || promo == 4 || promo == 5 ||
                promo == 10 || promo == 11 || promo == 12 || promo == 13;
            if (!validPromo) revert InvalidPromotion();
        }

        IChessLib.PackedBoard memory afterMove = CHESS.applyMove(
            board,
            move.from,
            move.to,
            move.promotion
        );
        if (CHESS.hashBoard(afterMove) != move.newBoardHash) revert MoveHashMismatch();

        g.status = GameStatus.Disputed;
        g.highestSequence = move.sequence;
        g.disputeDeadline = uint64(block.timestamp + DISPUTE_PERIOD);
        g.lastBoardHash = move.newBoardHash;
        g.lastToMove = mover;
        g.provisionalWinner = address(0);
        g.provisionalResult = ResultType.None;

        uint8 terminal = CHESS.evaluateTerminal(afterMove);
        if (terminal == 1) {
            g.provisionalWinner = g.playerWhite;
            g.provisionalResult = ResultType.Checkmate;
        } else if (terminal == 2) {
            g.provisionalWinner = g.playerBlack;
            g.provisionalResult = ResultType.Checkmate;
        } else if (terminal == 3) {
            g.provisionalWinner = address(0);
            g.provisionalResult = ResultType.Draw;
        }

        emit MoveChallenged(gameId, move.sequence, mover, move.newBoardHash);
        emit DisputeOpened(gameId, msg.sender, move.sequence, g.disputeDeadline);
    }

    function claimTerminalPosition(
        UnilateralTerminalClaim calldata claim
    ) external nonReentrant {
        Game storage g = games[claim.gameId];
        if (g.playerWhite == address(0)) revert GameNotFound();
        if (g.status != GameStatus.Active && g.status != GameStatus.Disputed) revert InvalidStatus();
        if (msg.sender != g.playerWhite && msg.sender != g.playerBlack) revert NotPlayer();

        _verifyGameStateSignatures(g, claim.lastMutualState, claim.sigWhite, claim.sigBlack);
        if (claim.lastMutualState.gameId != claim.gameId) revert GameNotFound();
        if (claim.lastMutualState.sequence < g.highestSequence) revert SequenceTooLow();

        if (!CHESS.validateBoard(claim.boardAtMutualState)) revert InvalidBoard();
        if (CHESS.hashBoard(claim.boardAtMutualState) != claim.lastMutualState.boardHash) revert BoardHashMismatch();

        if (claim.finalMove.sequence != claim.lastMutualState.sequence + 1) revert BadSequence();
        if (claim.finalMove.gameId != claim.gameId) revert GameNotFound();

        bytes32 mutualStateHash = keccak256(abi.encode(
            GAME_STATE_TYPEHASH,
            claim.lastMutualState.gameId,
            claim.lastMutualState.sequence,
            claim.lastMutualState.boardHash,
            claim.lastMutualState.currentPlayer,
            claim.lastMutualState.whiteTimeRemaining,
            claim.lastMutualState.blackTimeRemaining,
            claim.lastMutualState.lastActionTimestamp,
            claim.lastMutualState.previousStateHash
        ));
        if (claim.finalMove.previousStateHash != mutualStateHash) revert InvalidPreviousStateHash();

        address expectedPlayer = claim.boardAtMutualState.whiteToMove ? g.playerWhite : g.playerBlack;
        if (claim.lastMutualState.currentPlayer != expectedPlayer) revert StateBoardMismatch();

        address mover = claim.lastMutualState.currentPlayer;
        bytes32 moveDigest = _hashTypedDataV4(keccak256(abi.encode(
            SIGNED_MOVE_TYPEHASH,
            claim.finalMove.gameId,
            claim.finalMove.sequence,
            claim.finalMove.previousStateHash,
            claim.finalMove.newBoardHash,
            claim.finalMove.from,
            claim.finalMove.to,
            claim.finalMove.promotion
        )));
        address recovered = _recover(moveDigest, claim.finalMoveSignature);
        if (!_isAuthorizedSigner(claim.gameId, mover, recovered)) revert WrongMover();

        uint8 promo = claim.finalMove.promotion;
        if (promo != 0) {
            bool validPromo =
                promo == 2 || promo == 3 || promo == 4 || promo == 5 ||
                promo == 10 || promo == 11 || promo == 12 || promo == 13;
            if (!validPromo) revert InvalidPromotion();
        }

        IChessLib.PackedBoard memory afterMove = CHESS.applyMove(
            claim.boardAtMutualState,
            claim.finalMove.from,
            claim.finalMove.to,
            claim.finalMove.promotion
        );
        if (CHESS.hashBoard(afterMove) != claim.finalMove.newBoardHash) revert MoveHashMismatch();

        uint8 terminal = CHESS.evaluateTerminal(afterMove);
        if (terminal == 0) revert NotTerminal();

        address winner;
        ResultType rType;
        if (terminal == 1) {
            winner = g.playerWhite;
            rType = ResultType.Checkmate;
        } else if (terminal == 2) {
            winner = g.playerBlack;
            rType = ResultType.Checkmate;
        } else {
            winner = address(0);
            rType = ResultType.Draw;
        }

        g.status = GameStatus.Disputed;
        g.highestSequence = claim.finalMove.sequence;
        g.disputeDeadline = uint64(block.timestamp + DISPUTE_PERIOD);
        g.provisionalWinner = winner;
        g.provisionalResult = rType;
        g.lastBoardHash = claim.finalMove.newBoardHash;

        emit TerminalClaimed(claim.gameId, msg.sender, winner);
        emit DisputeOpened(claim.gameId, msg.sender, claim.finalMove.sequence, g.disputeDeadline);
    }

    function _finalize(uint256 gameId, address winner, ResultType resultType) internal {
        Game storage g = games[gameId];
        if (g.status == GameStatus.Finished || g.status == GameStatus.Cancelled) revert AlreadyFinished();

        g.status = GameStatus.Finished;
        g.winner = winner;
        g.resultType = resultType;
        g.prizeClaimed = true;

        address token = g.wagerToken;
        uint256 whiteAmt = g.whiteDeposited;
        uint256 blackAmt = g.blackDeposited;
        uint256 pot = g.totalPot;

        g.whiteDeposited = 0;
        g.blackDeposited = 0;
        g.totalPot = 0;

        // Draw → ieder krijgt terug wat hij écht heeft gestort
        if (winner == address(0) || resultType == ResultType.Draw || resultType == ResultType.AgreedDraw) {
            if (whiteAmt > 0) IERC20(token).safeTransfer(g.playerWhite, whiteAmt);
            if (blackAmt > 0) IERC20(token).safeTransfer(g.playerBlack, blackAmt);
            emit GameSettled(gameId, address(0), resultType, pot, 0);
            return;
        }

        // Winnaar krijgt pot minus fee
        uint256 fee = (pot * PLATFORM_FEE_BPS) / BPS_DENOMINATOR;
        uint256 payout = pot - fee;

        if (fee > 0) {
            IERC20(token).safeTransfer(feeRecipient, fee);
            totalFeesCollected[token] += fee;
            emit FeeCollected(token, fee, feeRecipient);
        }

        if (payout > 0) {
            IERC20(token).safeTransfer(winner, payout);
        }

        emit GameSettled(gameId, winner, resultType, payout, fee);
    }

    function _verifyGameStateSignatures(
        Game storage g,
        GameState calldata state,
        bytes calldata sigWhite,
        bytes calldata sigBlack
    ) internal view {
        bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(
            GAME_STATE_TYPEHASH,
            state.gameId, state.sequence, state.boardHash, state.currentPlayer,
            state.whiteTimeRemaining, state.blackTimeRemaining,
            state.lastActionTimestamp, state.previousStateHash
        )));
        address w = _recover(digest, sigWhite);
        address b = _recover(digest, sigBlack);
        if (!_isAuthorizedSigner(state.gameId, g.playerWhite, w)) revert InvalidSignature();
        if (!_isAuthorizedSigner(state.gameId, g.playerBlack, b)) revert InvalidSignature();
    }

    function _recover(bytes32 digest, bytes memory signature) internal pure returns (address) {
        require(signature.length == 65, "BAD_SIG");
        bytes32 r; bytes32 s; uint8 v;
        assembly ("memory-safe") {
            r := mload(add(signature, 32))
            s := mload(add(signature, 64))
            v := byte(0, mload(add(signature, 96)))
        }
        if (v < 27) v += 27;
        address signer = ecrecover(digest, v, r, s);
        require(signer != address(0), "INVALID_SIG");
        return signer;
    }

    function getGame(uint256 gameId) external view returns (Game memory) {
        return games[gameId];
    }

    function rescueNonWagerToken(address token, address to, uint256 amount) external onlyOwner {
        if (to == address(0)) revert ZeroAddress();
        if (isSupportedToken(token)) revert CannotRescueWagerToken();
        IERC20(token).safeTransfer(to, amount);
    }
}