Skip to content
Chain 4441 · LiteForge TestnetHead#0
LitVM·Scan
Hard Money Web3 Explorer
Verified Contract
0x420DA43A7DfA95a92506019588bbB70f256bf7aB
Pin to watchlist
scan to copy
zkLTC Balance
82.8462
hard money native
Transactions
204,469
Token transfers
5
Validations
0
blocks produced
Hard Money Score
100
/100
PURE HARD MONEY
share of activity in native zkLTC vs. tokens
native
204,469
tokens
5
Contract name
LitGamesMinesMain
Compiler
v0.8.24+commit.e11b9ed9
Optimization
enabled · 300 runs
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {MerkleProof} from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";

/// @title LitGamesMinesMain
/// @notice Long-lived LitGames Mines contract with owner-managed stake options.
/// @dev Flow: createGame (on-chain) -> reveal off-chain -> finalizeCashOut/finalizeLoss (on-chain)
contract LitGamesMinesMain is Ownable2Step, Pausable, ReentrancyGuard, EIP712 {
    uint8 public constant TOTAL_CELLS = 16;
    uint32 public constant BPS = 10_000;
    uint32 public constant RTP_BPS = 9_500;
    uint8 public constant MIN_BOMBS = 2;
    uint8 public constant MAX_BOMBS = 5;
    uint8 public constant MAX_ALLOWED_STAKE_OPTIONS = 16;
    uint64 public constant MIN_GAME_TIMEOUT = 15 seconds;
    uint64 public constant MAX_GAME_TIMEOUT = 15 minutes;
    uint32 public constant MIN_LOCK_MULTIPLIER_BPS = BPS;
    uint32 public constant MAX_LOCK_MULTIPLIER_BPS = 5_000_000; // 500x
    uint64 public constant MAX_TIMEOUT_COOLDOWN = 1 days;

    bytes32 private constant GAME_QUOTE_TYPEHASH =
        keccak256(
            "GameQuote(address player,uint96 stake,uint8 bombs,bytes32 boardRoot,uint64 validUntil,uint256 nonce)"
        );

    error InvalidSigner();
    error InvalidQuote();
    error QuoteExpired();
    error NonceAlreadyUsed();
    error InsufficientStake();
    error InvalidBombCount();
    error InvalidCell();
    error CellAlreadyOpened();
    error InvalidProof();
    error InvalidRevealBatch();
    error GameNotFound();
    error NotGamePlayer();
    error GameResolved();
    error GameStillActive();
    error NoSafeCellsOpened();
    error NotEnoughBankroll();
    error InvalidAddress();
    error LiquidityLocked();
    error NotAuthorizedRelayer();
    error PlayerHasActiveGame();
    error SessionNotAuthorized();
    error SessionExpired();
    error StakeAboveSessionLimit();
    error InsufficientPlayerBalance();
    error InvalidStakeOption();
    error InvalidGameTimeout();
    error InvalidLockMultiplier();
    error InvalidTimeoutCooldown();
    error NoPendingPayout();
    error PendingPayoutNotReady();
    error CooldownActive();

    struct GameQuote {
        address player;
        uint96 stake;
        uint8 bombs;
        bytes32 boardRoot;
        uint64 validUntil;
        uint256 nonce;
    }

    struct Game {
        address player;
        uint96 stake;
        uint96 maxPayout;
        uint96 reservedLiquidity;
        uint96 payout;
        uint8 bombs;
        uint8 safeOpened;
        uint32 resultMultiplierBps;
        uint16 openedMask;
        uint64 createdAt;
        uint64 timeoutAt;
        bool resolved;
        bool won;
        bool usesSessionBalance;
        bytes32 boardRoot;
        bytes32 gameKey;
    }

    struct SessionConfig {
        address sessionKey;
        uint64 validUntil;
        uint96 maxStake;
    }

    address public quoteSigner;
    uint256 public nextGameId = 1;
    uint256 public lockedLiquidity;
    uint256 public totalPlayerBalances;
    uint256 public totalPendingPayouts;
    uint64 public gameTimeout;
    uint64 public timeoutCooldown = 60;
    uint32 public lockMultiplier2BombsBps = 100_000; // 10x
    uint32 public lockMultiplier3BombsBps = 100_000; // 10x
    uint32 public lockMultiplier4BombsBps = 100_000; // 10x
    uint32 public lockMultiplier5BombsBps = 100_000; // 10x
    uint96[] private allowedStakes;

    mapping(uint256 => Game) public games;
    mapping(address => mapping(uint256 => bool)) public nonceUsed;
    mapping(address => uint256) public activeGameByPlayer;
    mapping(address => bool) public relayers;
    mapping(uint96 => bool) public isAllowedStake;
    mapping(address => uint256) public playerBalances;
    mapping(address => SessionConfig) public sessions;
    mapping(address => uint256) public pendingPayouts;
    mapping(address => uint64) public nextPlayableAt;
    mapping(address => uint32) public timeoutRefundStreak;

    event QuoteSignerUpdated(address indexed previousSigner, address indexed newSigner);
    event BankrollFunded(address indexed sender, uint256 amount, uint256 balanceAfter);
    event BankrollWithdrawn(address indexed to, uint256 amount, uint256 balanceAfter);
    event GameCreated(
        uint256 indexed gameId,
        address indexed player,
        uint96 stake,
        uint8 bombs,
        bytes32 boardRoot,
        uint96 maxPayout,
        bytes32 gameKey
    );
    event GameFinalized(
        uint256 indexed gameId,
        address indexed player,
        bool won,
        uint8 safeOpened,
        uint32 multiplierBps,
        uint96 payout
    );
    event RelayerUpdated(address indexed relayer, bool enabled);
    event GameRefunded(uint256 indexed gameId, address indexed player, uint96 stake);
    event PausedByOwner(address indexed account);
    event UnpausedByOwner(address indexed account);
    event SessionConfigured(address indexed player, address indexed sessionKey, uint64 validUntil, uint96 maxStake);
    event SessionRevoked(address indexed player);
    event PlayerBalanceDeposited(address indexed player, uint256 amount, uint256 balanceAfter);
    event PlayerBalanceWithdrawn(address indexed player, uint256 amount, uint256 balanceAfter);
    event AllowedStakesUpdated(uint96[] stakes);
    event GameTimeoutUpdated(uint64 previousTimeout, uint64 nextTimeout);
    event TimeoutCooldownUpdated(uint64 previousCooldown, uint64 nextCooldown);
    event LockMultipliersUpdated(
        uint32 prev2BombsBps,
        uint32 prev3BombsBps,
        uint32 prev4BombsBps,
        uint32 prev5BombsBps,
        uint32 next2BombsBps,
        uint32 next3BombsBps,
        uint32 next4BombsBps,
        uint32 next5BombsBps
    );
    event PayoutDeferred(uint256 indexed gameId, address indexed player, uint96 payout);
    event PendingPayoutClaimed(address indexed player, uint256 amount);
    event CooldownApplied(address indexed player, uint32 timeoutStreak, uint64 nextPlayableAt);

    constructor(address initialQuoteSigner, uint96[] memory initialAllowedStakes, uint64 initialGameTimeout)
        Ownable(msg.sender)
        EIP712("LitGamesReaperMines", "4")
    {
        if (initialQuoteSigner == address(0)) {
            revert InvalidAddress();
        }
        quoteSigner = initialQuoteSigner;
        _setAllowedStakes(initialAllowedStakes);
        _setGameTimeout(initialGameTimeout);
        emit AllowedStakesUpdated(initialAllowedStakes);
        emit GameTimeoutUpdated(0, initialGameTimeout);
    }

    receive() external payable {
        emit BankrollFunded(msg.sender, msg.value, address(this).balance);
    }

    function setQuoteSigner(address newSigner) external onlyOwner {
        if (newSigner == address(0)) {
            revert InvalidAddress();
        }
        address prev = quoteSigner;
        quoteSigner = newSigner;
        emit QuoteSignerUpdated(prev, newSigner);
    }

    function pause() external onlyOwner {
        _pause();
        emit PausedByOwner(msg.sender);
    }

    function unpause() external onlyOwner {
        _unpause();
        emit UnpausedByOwner(msg.sender);
    }

    function fundBankroll() external payable onlyOwner {
        emit BankrollFunded(msg.sender, msg.value, address(this).balance);
    }

    function setRelayer(address relayer, bool enabled) external onlyOwner {
        if (relayer == address(0)) {
            revert InvalidAddress();
        }
        relayers[relayer] = enabled;
        emit RelayerUpdated(relayer, enabled);
    }

    function getAllowedStakes() external view returns (uint96[] memory) {
        return allowedStakes;
    }

    function setAllowedStakes(uint96[] calldata newAllowedStakes) external onlyOwner {
        _setAllowedStakes(newAllowedStakes);
        emit AllowedStakesUpdated(newAllowedStakes);
    }

    function setGameTimeout(uint64 newGameTimeout) external onlyOwner {
        uint64 previousTimeout = gameTimeout;
        _setGameTimeout(newGameTimeout);
        emit GameTimeoutUpdated(previousTimeout, newGameTimeout);
    }

    function setTimeoutCooldown(uint64 newTimeoutCooldown) external onlyOwner {
        uint64 previousCooldown = timeoutCooldown;
        _setTimeoutCooldown(newTimeoutCooldown);
        emit TimeoutCooldownUpdated(previousCooldown, newTimeoutCooldown);
    }

    function setLockMultipliersByBombs(
        uint32 next2BombsBps,
        uint32 next3BombsBps,
        uint32 next4BombsBps,
        uint32 next5BombsBps
    ) external onlyOwner {
        _requireValidLockMultiplier(next2BombsBps);
        _requireValidLockMultiplier(next3BombsBps);
        _requireValidLockMultiplier(next4BombsBps);
        _requireValidLockMultiplier(next5BombsBps);

        uint32 prev2 = lockMultiplier2BombsBps;
        uint32 prev3 = lockMultiplier3BombsBps;
        uint32 prev4 = lockMultiplier4BombsBps;
        uint32 prev5 = lockMultiplier5BombsBps;

        lockMultiplier2BombsBps = next2BombsBps;
        lockMultiplier3BombsBps = next3BombsBps;
        lockMultiplier4BombsBps = next4BombsBps;
        lockMultiplier5BombsBps = next5BombsBps;

        emit LockMultipliersUpdated(prev2, prev3, prev4, prev5, next2BombsBps, next3BombsBps, next4BombsBps, next5BombsBps);
    }

    function withdrawBankroll(address payable to, uint256 amount) external onlyOwner nonReentrant {
        if (to == address(0)) {
            revert InvalidAddress();
        }
        uint256 freeBalance = _freeBalance();
        if (amount > freeBalance) {
            revert LiquidityLocked();
        }
        (bool ok, ) = to.call{value: amount}("");
        require(ok, "Withdraw failed");
        emit BankrollWithdrawn(to, amount, address(this).balance);
    }

    function configureSession(address sessionKey, uint64 validUntil, uint96 maxStake) external whenNotPaused {
        _setSession(msg.sender, sessionKey, validUntil, maxStake);
    }

    function configureSessionAndDeposit(address sessionKey, uint64 validUntil, uint96 maxStake)
        external
        payable
        whenNotPaused
    {
        _setSession(msg.sender, sessionKey, validUntil, maxStake);
        if (msg.value > 0) {
            playerBalances[msg.sender] += msg.value;
            totalPlayerBalances += msg.value;
            emit PlayerBalanceDeposited(msg.sender, msg.value, playerBalances[msg.sender]);
        }
    }

    function revokeSession() external {
        delete sessions[msg.sender];
        emit SessionRevoked(msg.sender);
    }

    function depositPlayerBalance() external payable whenNotPaused {
        if (msg.value == 0) {
            revert InsufficientStake();
        }
        playerBalances[msg.sender] += msg.value;
        totalPlayerBalances += msg.value;
        emit PlayerBalanceDeposited(msg.sender, msg.value, playerBalances[msg.sender]);
    }

    function withdrawPlayerBalance(uint256 amount) external nonReentrant {
        if (amount == 0) {
            revert InsufficientStake();
        }
        uint256 bal = playerBalances[msg.sender];
        if (bal < amount) {
            revert InsufficientPlayerBalance();
        }
        playerBalances[msg.sender] = bal - amount;
        totalPlayerBalances -= amount;
        (bool ok, ) = payable(msg.sender).call{value: amount}("");
        require(ok, "Player withdraw failed");
        emit PlayerBalanceWithdrawn(msg.sender, amount, playerBalances[msg.sender]);
    }

    function claimPendingPayout() external nonReentrant {
        uint256 amount = pendingPayouts[msg.sender];
        if (amount == 0) {
            revert NoPendingPayout();
        }

        uint256 liabilitiesExcludingClaim = lockedLiquidity + totalPlayerBalances + (totalPendingPayouts - amount);
        uint256 balance = address(this).balance;
        if (balance <= liabilitiesExcludingClaim) {
            revert PendingPayoutNotReady();
        }
        uint256 availableForClaim = balance - liabilitiesExcludingClaim;
        if (availableForClaim < amount) {
            revert PendingPayoutNotReady();
        }

        pendingPayouts[msg.sender] = 0;
        totalPendingPayouts -= amount;
        (bool ok, ) = payable(msg.sender).call{value: amount}("");
        require(ok, "Pending payout transfer failed");
        emit PendingPayoutClaimed(msg.sender, amount);
    }

    function getSession(address player) external view returns (address sessionKey, uint64 validUntil, uint96 maxStake) {
        SessionConfig memory s = sessions[player];
        return (s.sessionKey, s.validUntil, s.maxStake);
    }

    function createGame(GameQuote calldata quote, bytes calldata signature)
        external
        payable
        nonReentrant
        whenNotPaused
        returns (uint256 gameId)
    {
        if (msg.sender != quote.player || quote.player == address(0)) {
            revert InvalidQuote();
        }
        _requireNotInCooldown(msg.sender);
        uint256 activeGameId = activeGameByPlayer[msg.sender];
        if (activeGameId != 0) {
            Game storage active = games[activeGameId];
            if (!active.resolved) {
                revert PlayerHasActiveGame();
            }
            activeGameByPlayer[msg.sender] = 0;
        }
        if (block.timestamp > quote.validUntil) {
            revert QuoteExpired();
        }
        if (quote.stake == 0 || msg.value != quote.stake) {
            revert InsufficientStake();
        }
        _requireAllowedStake(quote.stake);
        if (quote.bombs < MIN_BOMBS || quote.bombs > MAX_BOMBS) {
            revert InvalidBombCount();
        }
        if (quote.boardRoot == bytes32(0)) {
            revert InvalidQuote();
        }
        if (nonceUsed[msg.sender][quote.nonce]) {
            revert NonceAlreadyUsed();
        }
        _verifyQuote(quote, signature);
        nonceUsed[msg.sender][quote.nonce] = true;

        uint96 maxPayout = uint96(_payoutFromStake(quote.stake, quote.bombs, TOTAL_CELLS - quote.bombs));
        uint96 reservedLiquidity = 0;

        bytes32 gameKey = _gameKey(msg.sender, quote.nonce);
        gameId = nextGameId++;
        games[gameId] = Game({
            player: msg.sender,
            stake: quote.stake,
            maxPayout: maxPayout,
            reservedLiquidity: reservedLiquidity,
            payout: 0,
            bombs: quote.bombs,
            safeOpened: 0,
            resultMultiplierBps: BPS,
            openedMask: 0,
            createdAt: uint64(block.timestamp),
            timeoutAt: uint64(block.timestamp) + gameTimeout,
            resolved: false,
            won: false,
            usesSessionBalance: false,
            boardRoot: quote.boardRoot,
            gameKey: gameKey
        });

        activeGameByPlayer[msg.sender] = gameId;
        emit GameCreated(gameId, msg.sender, quote.stake, quote.bombs, quote.boardRoot, maxPayout, gameKey);
    }

    /// @notice Session + relayer path: player signs session once, then relayer creates rounds using player escrow balance.
    function createGameByRelayer(address player, GameQuote calldata quote, bytes calldata signature)
        external
        nonReentrant
        whenNotPaused
        returns (uint256 gameId)
    {
        _requireRelayer();
        if (player == address(0) || quote.player != player) {
            revert InvalidQuote();
        }
        _requireNotInCooldown(player);
        _requireActiveSession(player, quote.stake);

        uint256 activeGameId = activeGameByPlayer[player];
        if (activeGameId != 0) {
            Game storage active = games[activeGameId];
            if (!active.resolved) {
                revert PlayerHasActiveGame();
            }
            activeGameByPlayer[player] = 0;
        }
        if (block.timestamp > quote.validUntil) {
            revert QuoteExpired();
        }
        if (quote.stake == 0) {
            revert InsufficientStake();
        }
        _requireAllowedStake(quote.stake);
        if (quote.bombs < MIN_BOMBS || quote.bombs > MAX_BOMBS) {
            revert InvalidBombCount();
        }
        if (quote.boardRoot == bytes32(0)) {
            revert InvalidQuote();
        }
        if (nonceUsed[player][quote.nonce]) {
            revert NonceAlreadyUsed();
        }
        _verifyQuote(quote, signature);
        nonceUsed[player][quote.nonce] = true;

        uint256 userBalance = playerBalances[player];
        if (userBalance < quote.stake) {
            revert InsufficientPlayerBalance();
        }
        playerBalances[player] = userBalance - quote.stake;
        totalPlayerBalances -= quote.stake;

        uint96 maxPayout = uint96(_payoutFromStake(quote.stake, quote.bombs, TOTAL_CELLS - quote.bombs));
        uint96 reservedLiquidity = 0;

        bytes32 gameKey = _gameKey(player, quote.nonce);
        gameId = nextGameId++;
        games[gameId] = Game({
            player: player,
            stake: quote.stake,
            maxPayout: maxPayout,
            reservedLiquidity: reservedLiquidity,
            payout: 0,
            bombs: quote.bombs,
            safeOpened: 0,
            resultMultiplierBps: BPS,
            openedMask: 0,
            createdAt: uint64(block.timestamp),
            timeoutAt: uint64(block.timestamp) + gameTimeout,
            resolved: false,
            won: false,
            usesSessionBalance: true,
            boardRoot: quote.boardRoot,
            gameKey: gameKey
        });

        activeGameByPlayer[player] = gameId;
        emit GameCreated(gameId, player, quote.stake, quote.bombs, quote.boardRoot, maxPayout, gameKey);
    }

    /// @notice Finalize a winning game by proving all safe cells the player opened off-chain.
    function finalizeCashOut(
        uint256 gameId,
        uint8[] calldata safeCellIndexes,
        bytes32[] calldata safeSalts,
        bytes32[][] calldata safeProofs
    ) external nonReentrant whenNotPaused returns (uint96 payout) {
        Game storage g = games[gameId];
        _requireActiveGame(g);
        g.safeOpened = _verifyAndApplySafeBatch(g, safeCellIndexes, safeSalts, safeProofs);

        uint32 multiplierBps = _multiplierBps(g.bombs, g.safeOpened);
        payout = uint96((uint256(g.stake) * multiplierBps) / BPS);

        _resolve(gameId, g, true, multiplierBps, payout);
    }

    /// @notice Finalize a losing game by proving the mine cell that ended the round.
    function finalizeLoss(uint256 gameId, uint8 mineCellIndex, bytes32 mineSalt, bytes32[] calldata mineProof)
        external
        nonReentrant
        whenNotPaused
    {
        Game storage g = games[gameId];
        _requireActiveGame(g);

        if (mineCellIndex >= TOTAL_CELLS) {
            revert InvalidCell();
        }

        uint16 bit = uint16(1) << mineCellIndex;
        if ((g.openedMask & bit) != 0) {
            revert CellAlreadyOpened();
        }

        bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(g.gameKey, mineCellIndex, true, mineSalt))));
        if (!MerkleProof.verifyCalldata(mineProof, g.boardRoot, leaf)) {
            revert InvalidProof();
        }

        g.openedMask |= bit;
        _resolve(gameId, g, false, 0, 0);
    }

    /// @notice Relayer finalization path for cash out (player signs only createGame).
    function finalizeCashOutByRelayer(
        uint256 gameId,
        address player,
        uint8[] calldata safeCellIndexes,
        bytes32[] calldata safeSalts,
        bytes32[][] calldata safeProofs
    ) external nonReentrant whenNotPaused returns (uint96 payout) {
        _requireRelayer();
        Game storage g = games[gameId];
        _requireActiveGameForPlayer(g, player);
        g.safeOpened = _verifyAndApplySafeBatch(g, safeCellIndexes, safeSalts, safeProofs);
        uint32 multiplierBps = _multiplierBps(g.bombs, g.safeOpened);
        payout = uint96((uint256(g.stake) * multiplierBps) / BPS);
        _resolve(gameId, g, true, multiplierBps, payout);
    }

    /// @notice Relayer finalization path for losses.
    function finalizeLossByRelayer(
        uint256 gameId,
        address player,
        uint8 mineCellIndex,
        bytes32 mineSalt,
        bytes32[] calldata mineProof
    ) external nonReentrant whenNotPaused {
        _requireRelayer();
        Game storage g = games[gameId];
        _requireActiveGameForPlayer(g, player);

        if (mineCellIndex >= TOTAL_CELLS) {
            revert InvalidCell();
        }

        uint16 bit = uint16(1) << mineCellIndex;
        if ((g.openedMask & bit) != 0) {
            revert CellAlreadyOpened();
        }

        bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(g.gameKey, mineCellIndex, true, mineSalt))));
        if (!MerkleProof.verifyCalldata(mineProof, g.boardRoot, leaf)) {
            revert InvalidProof();
        }

        g.openedMask |= bit;
        _resolve(gameId, g, false, 0, 0);
    }

    function timeoutRefund(uint256 gameId) external nonReentrant {
        Game storage g = games[gameId];
        if (g.player == address(0)) {
            revert GameNotFound();
        }
        if (g.resolved) {
            revert GameResolved();
        }
        if (block.timestamp < uint256(g.timeoutAt)) {
            revert GameStillActive();
        }

        g.resolved = true;
        g.won = false;
        g.resultMultiplierBps = BPS;
        g.payout = g.stake;
        lockedLiquidity -= g.reservedLiquidity;
        activeGameByPlayer[g.player] = 0;
        uint32 streak = ++timeoutRefundStreak[g.player];
        if (streak >= 2 && timeoutCooldown > 0) {
            uint64 playableAt = uint64(block.timestamp) + timeoutCooldown;
            nextPlayableAt[g.player] = playableAt;
            emit CooldownApplied(g.player, streak, playableAt);
        }
        if (g.usesSessionBalance) {
            playerBalances[g.player] += g.stake;
            totalPlayerBalances += g.stake;
        } else {
            (bool ok, ) = payable(g.player).call{value: g.stake}("");
            if (!ok) {
                uint256 deferredAmount = uint256(g.stake);
                pendingPayouts[g.player] += deferredAmount;
                totalPendingPayouts += deferredAmount;
                emit PayoutDeferred(gameId, g.player, g.stake);
            }
        }
        emit GameRefunded(gameId, g.player, g.stake);
    }

    function currentMultiplierBps(uint256 gameId) external view returns (uint32) {
        Game storage g = games[gameId];
        if (g.player == address(0)) {
            revert GameNotFound();
        }
        if (g.resolved || g.safeOpened == 0) {
            return BPS;
        }
        return _multiplierBps(g.bombs, g.safeOpened);
    }

    function potentialPayout(uint256 gameId) external view returns (uint256) {
        Game storage g = games[gameId];
        if (g.player == address(0)) {
            revert GameNotFound();
        }
        if (g.safeOpened == 0) {
            return g.stake;
        }
        return _payoutFromStake(g.stake, g.bombs, g.safeOpened);
    }

    function _resolve(uint256 gameId, Game storage g, bool won, uint32 multiplierBps, uint96 payout) internal {
        g.resolved = true;
        g.won = won;
        g.resultMultiplierBps = multiplierBps;
        g.payout = payout;
        lockedLiquidity -= g.reservedLiquidity;
        activeGameByPlayer[g.player] = 0;
        timeoutRefundStreak[g.player] = 0;

        if (won) {
            if (g.usesSessionBalance) {
                playerBalances[g.player] += payout;
                totalPlayerBalances += payout;
            } else {
                uint256 payoutAmount = uint256(payout);
                if (_freeBalance() >= payoutAmount) {
                    (bool ok, ) = payable(g.player).call{value: payout}("");
                    if (!ok) {
                        pendingPayouts[g.player] += payoutAmount;
                        totalPendingPayouts += payoutAmount;
                        emit PayoutDeferred(gameId, g.player, payout);
                    }
                } else {
                    pendingPayouts[g.player] += payoutAmount;
                    totalPendingPayouts += payoutAmount;
                    emit PayoutDeferred(gameId, g.player, payout);
                }
            }
        }

        emit GameFinalized(gameId, g.player, won, g.safeOpened, multiplierBps, payout);
    }

    function _requireActiveGame(Game storage g) internal view {
        if (g.player == address(0)) {
            revert GameNotFound();
        }
        if (g.player != msg.sender) {
            revert NotGamePlayer();
        }
        if (g.resolved) {
            revert GameResolved();
        }
    }

    function _requireOwnedUnresolvedGame(Game storage g) internal view {
        if (g.player == address(0)) {
            revert GameNotFound();
        }
        if (g.player != msg.sender) {
            revert NotGamePlayer();
        }
        if (g.resolved) {
            revert GameResolved();
        }
    }

    function _requireActiveGameForPlayer(Game storage g, address player) internal view {
        if (g.player == address(0)) {
            revert GameNotFound();
        }
        if (g.player != player || player == address(0)) {
            revert NotGamePlayer();
        }
        if (g.resolved) {
            revert GameResolved();
        }
    }

    function _requireRelayer() internal view {
        if (msg.sender != owner() && !relayers[msg.sender]) {
            revert NotAuthorizedRelayer();
        }
    }

    function _setSession(address player, address sessionKey, uint64 validUntil, uint96 maxStake) internal {
        if (sessionKey == address(0)) {
            revert InvalidAddress();
        }
        if (validUntil <= block.timestamp) {
            revert QuoteExpired();
        }
        if (maxStake == 0) {
            revert InsufficientStake();
        }
        sessions[player] = SessionConfig({sessionKey: sessionKey, validUntil: validUntil, maxStake: maxStake});
        emit SessionConfigured(player, sessionKey, validUntil, maxStake);
    }

    function _requireActiveSession(address player, uint96 stake) internal view {
        SessionConfig memory s = sessions[player];
        if (s.sessionKey == address(0)) {
            revert SessionNotAuthorized();
        }
        if (msg.sender != s.sessionKey) {
            revert NotAuthorizedRelayer();
        }
        if (block.timestamp > s.validUntil) {
            revert SessionExpired();
        }
        if (stake > s.maxStake) {
            revert StakeAboveSessionLimit();
        }
    }

    function _verifyQuote(GameQuote calldata quote, bytes calldata signature) internal view {
        bytes32 structHash = keccak256(
            abi.encode(
                GAME_QUOTE_TYPEHASH,
                quote.player,
                quote.stake,
                quote.bombs,
                quote.boardRoot,
                quote.validUntil,
                quote.nonce
            )
        );
        bytes32 digest = _hashTypedDataV4(structHash);
        address recovered = ECDSA.recover(digest, signature);
        if (recovered != quoteSigner) {
            revert InvalidSigner();
        }
    }

    function _verifyAndApplySafeBatch(
        Game storage g,
        uint8[] calldata safeCellIndexes,
        bytes32[] calldata safeSalts,
        bytes32[][] calldata safeProofs
    ) internal returns (uint8) {
        uint256 len = safeCellIndexes.length;
        if (len == 0 || safeSalts.length != len || safeProofs.length != len) {
            revert InvalidRevealBatch();
        }

        uint256 safeCap = TOTAL_CELLS - g.bombs;
        if (len > safeCap) {
            revert InvalidRevealBatch();
        }

        uint16 openedMaskLocal = g.openedMask;
        for (uint256 i = 0; i < len; i++) {
            uint8 cellIndex = safeCellIndexes[i];
            if (cellIndex >= TOTAL_CELLS) {
                revert InvalidCell();
            }

            uint16 bit = uint16(1) << cellIndex;
            if ((openedMaskLocal & bit) != 0) {
                revert CellAlreadyOpened();
            }

            bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(g.gameKey, cellIndex, false, safeSalts[i]))));
            if (!MerkleProof.verifyCalldata(safeProofs[i], g.boardRoot, leaf)) {
                revert InvalidProof();
            }

            openedMaskLocal |= bit;
        }

        g.openedMask = openedMaskLocal;
        return uint8(len);
    }

    function _gameKey(address player, uint256 nonce) internal pure returns (bytes32) {
        return keccak256(abi.encode(player, nonce));
    }

    function _multiplierBps(uint8 bombs, uint8 safeOpened) internal pure returns (uint32) {
        if (safeOpened == 0) {
            return BPS;
        }

        uint256 safeTotal = TOTAL_CELLS - bombs;
        uint256 numer = 1;
        uint256 denom = 1;
        for (uint256 i = 0; i < safeOpened; i++) {
            numer *= safeTotal - i;
            denom *= TOTAL_CELLS - i;
        }

        uint256 multiplier = (uint256(RTP_BPS) * denom) / numer;
        if (multiplier < BPS) {
            multiplier = BPS;
        }
        require(multiplier <= type(uint32).max, "Multiplier overflow");
        return uint32(multiplier);
    }

    function _payoutFromStake(uint96 stake, uint8 bombs, uint8 safeOpened) internal pure returns (uint256) {
        uint256 multiplierBps = _multiplierBps(bombs, safeOpened);
        return (uint256(stake) * multiplierBps) / BPS;
    }

    function _requireAllowedStake(uint96 stake) internal view {
        if (!isAllowedStake[stake]) {
            revert InvalidStakeOption();
        }
    }

    function _setAllowedStakes(uint96[] memory newAllowedStakes) internal {
        uint256 nextLen = newAllowedStakes.length;
        if (nextLen == 0 || nextLen > MAX_ALLOWED_STAKE_OPTIONS) {
            revert InvalidStakeOption();
        }

        uint96 previousStake;
        for (uint256 i = 0; i < nextLen; i++) {
            uint96 stake = newAllowedStakes[i];
            if (stake == 0 || (i > 0 && stake <= previousStake)) {
                revert InvalidStakeOption();
            }
            previousStake = stake;
        }

        uint256 currentLen = allowedStakes.length;
        for (uint256 i = 0; i < currentLen; i++) {
            isAllowedStake[allowedStakes[i]] = false;
        }
        delete allowedStakes;

        for (uint256 i = 0; i < nextLen; i++) {
            uint96 stake = newAllowedStakes[i];
            allowedStakes.push(stake);
            isAllowedStake[stake] = true;
        }
    }

    function _setGameTimeout(uint64 newGameTimeout) internal {
        if (newGameTimeout < MIN_GAME_TIMEOUT || newGameTimeout > MAX_GAME_TIMEOUT) {
            revert InvalidGameTimeout();
        }
        gameTimeout = newGameTimeout;
    }

    function _setTimeoutCooldown(uint64 newTimeoutCooldown) internal {
        if (newTimeoutCooldown > MAX_TIMEOUT_COOLDOWN) {
            revert InvalidTimeoutCooldown();
        }
        timeoutCooldown = newTimeoutCooldown;
    }

    function _lockedAmountForGame(uint96 stake, uint8 bombs, uint96 maxPayout) internal view returns (uint96) {
        uint256 lockByStake = (uint256(stake) * _lockMultiplierBpsForBombs(bombs)) / BPS;
        uint256 effectiveLock = lockByStake > uint256(maxPayout) ? uint256(maxPayout) : lockByStake;
        require(effectiveLock <= type(uint96).max, "Lock overflow");
        return uint96(effectiveLock);
    }

    function _lockMultiplierBpsForBombs(uint8 bombs) internal view returns (uint32) {
        if (bombs == 2) return lockMultiplier2BombsBps;
        if (bombs == 3) return lockMultiplier3BombsBps;
        if (bombs == 4) return lockMultiplier4BombsBps;
        if (bombs == 5) return lockMultiplier5BombsBps;
        revert InvalidBombCount();
    }

    function _requireValidLockMultiplier(uint32 value) internal pure {
        if (value < MIN_LOCK_MULTIPLIER_BPS || value > MAX_LOCK_MULTIPLIER_BPS) {
            revert InvalidLockMultiplier();
        }
    }

    function _requireNotInCooldown(address player) internal view {
        if (block.timestamp < uint256(nextPlayableAt[player])) {
            revert CooldownActive();
        }
    }

    function _freeBalance() internal view returns (uint256) {
        uint256 liabilities = lockedLiquidity + totalPlayerBalances + totalPendingPayouts;
        uint256 balance = address(this).balance;
        if (balance <= liabilities) {
            return 0;
        }
        return balance - liabilities;
    }
}