Address
0x421c9a85ecbe63d2114bc8daaec236cbb76cd41bCurrent 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);
}
}