MarianVault

0x4d229d43c1968d149fd049d50c271adddc244d86

Verification
Verified
v0.8.24+commit.e11b9ed9
Type
Contract
6,015 bytes
ABI entries
52
22 read · 15 write
License
none

Contract information

Address
0x4d229d43c1968d149fd049d50c271adddc244d86
Chain
Robinhood Chain (4663)
Compiler
v0.8.24+commit.e11b9ed9
Optimization
Enabled
Creation tx
0x1608c35f6d…c5b8f46525

Token

Name
Symbol
Decimals
Holders
0

Read contract (22)

MAX_CAP_BPS()uint16
MAX_PAYOUT_WINDOW_BPS()uint16
MAX_WITHDRAW_DELAY()uint32
MIN_WITHDRAW_DELAY()uint32
bankroll()uint256
capBps()uint16
credits(address)uint256
guardian()address
maxPayout()uint256
operator()address
owner()address
paused()bool
payoutInWindow()uint256
payoutWindow()uint32
payoutWindowBps()uint16
payoutWindowRemaining()uint256
payoutWindowStart()uint256
pendingWithdraw(address)uint256
settled(bytes32)bool
token()address
withdrawDelay()uint32
withdrawableAt(address)uint256

Events (14)

BankrollDefundedBankrollFundedCapSetDepositGuardianSetNetSettledOperatorSetOwnershipTransferredPausedSetPayoutWindowSetWithdrawWithdrawCancelledWithdrawDelaySetWithdrawRequested

ABI

[
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token_",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "operator_",
        "type": "address"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "constructor"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "bankroll",
        "type": "uint256"
      }
    ],
    "name": "BankrollDefunded",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "from",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "bankroll",
        "type": "uint256"
      }
    ],
    "name": "BankrollFunded",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": false,
        "internalType": "uint16",
        "name": "capBps",
        "type": "uint16"
      }
    ],
    "name": "CapSet",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "player",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "Deposit",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": false,
        "internalType": "address",
        "name": "guardian",
        "type": "address"
      }
    ],
    "name": "GuardianSet",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "bytes32",
        "name": "id",
        "type": "bytes32"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "player",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "sumStake",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "sumPayout",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "betCount",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "bytes32",
        "name": "merkleRoot",
        "type": "bytes32"
      },
      {
        "indexed": false,
        "internalType": "uint64",
        "name": "epoch",
        "type": "uint64"
      },
      {
        "indexed": false,
        "internalType": "int256",
        "name": "net",
        "type": "int256"
      }
    ],
    "name": "NetSettled",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": false,
        "internalType": "address",
        "name": "operator",
        "type": "address"
      }
    ],
    "name": "OperatorSet",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "from",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "to",
        "type": "address"
      }
    ],
    "name": "OwnershipTransferred",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": false,
        "internalType": "bool",
        "name": "paused",
        "type": "bool"
      }
    ],
    "name": "PausedSet",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": false,
        "internalType": "uint32",
        "name": "window",
        "type": "uint32"
      },
      {
        "indexed": false,
        "internalType": "uint16",
        "name": "bps",
        "type": "uint16"
      }
    ],
    "name": "PayoutWindowSet",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "player",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "Withdraw",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "player",
        "type": "address"
      }
    ],
    "name": "WithdrawCancelled",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": false,
        "internalType": "uint32",
        "name": "withdrawDelay",
        "type": "uint32"
      }
    ],
    "name": "WithdrawDelaySet",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "player",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "readyAt",
        "type": "uint256"
      }
    ],
    "name": "WithdrawRequested",
    "type": "event"
  },
  {
    "inputs": [],
    "name": "MAX_CAP_BPS",
    "outputs": [
      {
        "internalType": "uint16",
        "name": "",
        "type": "uint16"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "MAX_PAYOUT_WINDOW_BPS",
    "outputs": [
      {
        "internalType": "uint16",
        "name": "",
        "type": "uint16"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "MAX_WITHDRAW_DELAY",
    "outputs": [
      {
        "internalType": "uint32",
        "name": "",
        "type": "uint32"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "MIN_WITHDRAW_DELAY",
    "outputs": [
      {
        "internalType": "uint32",
        "name": "",
        "type": "uint32"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "bankroll",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "cancelWithdraw",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "capBps",
    "outputs": [
      {
        "internalType": "uint16",
        "name": "",
        "type": "uint16"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "name": "credits",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      },
      {
        "internalType": "address",
        "name": "to",
        "type": "address"
      }
    ],
    "name": "defundBankroll",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "deposit",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "fundBankroll",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "guardian",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "maxPayout",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "operator",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "owner",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "pause",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "paused",
    "outputs": [
      {
        "internalType": "bool",
        "name": "",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "payoutInWindow",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "payoutWindow",
    "outputs": [
      {
        "internalType": "uint32",
        "name": "",
        "type": "uint32"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "payoutWindowBps",
    "outputs": [
      {
        "internalType": "uint16",
        "name": "",
        "type": "uint16"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "payoutWindowRemaining",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "payoutWindowStart",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "name": "pendingWithdraw",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "requestWithdraw",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint16",
        "name": "bps",
        "type": "uint16"
      }
    ],
    "name": "setCapBps",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "g",
        "type": "address"
      }
    ],
    "name": "setGuardian",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "op",
        "type": "address"
      }
    ],
    "name": "setOperator",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint32",
        "name": "window",
        "type": "uint32"
      },
      {
        "internalType": "uint16",
        "name": "bps",
        "type": "uint16"
      }
    ],
    "name": "setPayoutWindow",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint32",
        "name": "d",
        "type": "uint32"
      }
    ],
    "name": "setWithdrawDelay",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "bytes32",
        "name": "id",
        "type": "bytes32"
      },
      {
        "internalType": "address",
        "name": "player",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "sumStake",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "sumPayout",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "betCount",
        "type": "uint256"
      },
      {
        "internalType": "bytes32",
        "name": "merkleRoot",
        "type": "bytes32"
      },
      {
        "internalType": "uint64",
        "name": "epoch",
        "type": "uint64"
      }
    ],
    "name": "settleNet",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "bytes32",
        "name": "",
        "type": "bytes32"
      }
    ],
    "name": "settled",
    "outputs": [
      {
        "internalType": "bool",
        "name": "",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "token",
    "outputs": [
      {
        "internalType": "contract IERC20",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "to",
        "type": "address"
      }
    ],
    "name": "transferOwnership",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "unpause",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "withdraw",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "withdrawDelay",
    "outputs": [
      {
        "internalType": "uint32",
        "name": "",
        "type": "uint32"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "name": "withdrawableAt",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  }
]

Source code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

/// @notice Minimal ERC-20 surface used by the vault.
interface IERC20 {
    function transfer(address to, uint256 amount) external returns (bool);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
    function balanceOf(address account) external view returns (uint256);
}

/**
 * @title MarianVault
 * @notice Custody + bankroll + cap-enforced settlement for the Forest Fortune game,
 *         denominated in $MARIAN (Lady Marian, 18 decimals).
 *
 * Trust model (custodial-with-guardrails):
 *   - Players deposit MARIAN -> on-chain `credits`. Withdrawal is a two-step,
 *     timelocked flow (`requestWithdraw` then `withdraw` after `withdrawDelay`).
 *     The delay guarantees any in-flight bet is settled before funds can leave,
 *     so a player cannot pull a stake that is mid-flight and then dodge the loss
 *     (`settleNet`'s loss path would otherwise revert "credit short"). Withdrawals
 *     remain trustless — the operator cannot block them, only the clock gates them.
 *   - The off-chain game runs an instant HMAC provably-fair scheme (mock-server/
 *     fairness.mjs) and batches results; the operator books each epoch's NET on-chain.
 *   - CUSTODIAL TRUST MODEL: the `operator` (game server key) is the sole settlement
 *     authority for BOTH wins and losses. Players trust the house with their on-chain
 *     credit balance (standard for a custodial casino); every game OUTCOME is still
 *     provably fair via drand. The on-chain guardrails CONTAIN a compromised operator
 *     rather than prevent misbehaviour cryptographically:
 *       1. CAP — no single payout exceeds `capBps` of the live bankroll (default 1%).
 *       2. CIRCUIT BREAKER — net win payouts are bounded to `payoutWindowBps` of the
 *          bankroll per rolling `payoutWindow`; a breach reverts.
 *       3. PAUSE — `owner` or `guardian` can freeze settlement AND withdrawals; the
 *          withdraw timelock buys time to react before any funds can leave.
 *       4. COLD OWNER — `defundBankroll` is owner-only, so the hot operator key can
 *          never pull the bankroll directly. Deploy with `owner` = cold multisig / HW.
 *     Net effect: an operator-key compromise is bounded, detectable, and freezable —
 *     not an instant total loss. It is NOT cryptographically prevented.
 *
 * Dynamic limits: `maxPayout()` = bankroll * capBps / 1e4. As the bankroll grows from the
 * house edge, the cap (and thus allowed bet sizes off-chain) rises automatically; if it
 * draws down, the cap shrinks. Off-chain limits MUST be derived from `bankroll()`.
 *
 * NOT YET AUDITED. Review before mainnet real-money use. See onchain/VERIFY.md.
 */
contract MarianVault {
    // ─── token ───────────────────────────────────────────────────────────────
    IERC20 public immutable token; // $MARIAN 0x01637b14B7378B99dE75A64d50656d98488D9a4d

    // ─── roles ───────────────────────────────────────────────────────────────
    address public owner; // treasury / admin (cold multisig / HW in production)
    address public operator; // game-server settlement authority (hot key)
    address public guardian; // emergency pauser (hot); can pause, only owner unpauses

    // ─── accounting ──────────────────────────────────────────────────────────
    mapping(address => uint256) public credits; // player game balances
    uint256 public bankroll; // house funds available for net payouts
    uint16 public capBps = 100; // payout cap = 1.00% of bankroll
    uint16 public constant MAX_CAP_BPS = 500; // owner can never raise above 5%

    // ─── safety ──────────────────────────────────────────────────────────────
    bool public paused;
    mapping(bytes32 => bool) public settled; // settlement id => applied (replay guard)
    uint256 private _lock = 1;

    // ─── withdrawal timelock ───────────────────────────────────────────────────
    // Two-step withdrawals: a request matures after `withdrawDelay`, which must
    // exceed the worst-case time for an in-flight bet to settle on-chain. This is
    // the C2 guardrail — without it a player could withdraw a staked credit before
    // its `settle` lands and the loss path ("credit short") could never apply.
    uint32 public withdrawDelay = 5 minutes; // default; owner-tunable within bounds
    uint32 public constant MIN_WITHDRAW_DELAY = 1 minutes; // floor protects the house
    uint32 public constant MAX_WITHDRAW_DELAY = 7 days; // ceiling protects players
    mapping(address => uint256) public pendingWithdraw; // requested amount (0 = none)
    mapping(address => uint256) public withdrawableAt; // unix ts the request matures

    // ─── circuit breaker ───────────────────────────────────────────────────────
    // Bounds total net win payouts (bankroll -> credits) per rolling window, so a
    // compromised operator can extract at most this much before the window resets
    // or the owner/guardian pauses. Fixed window: resets on the first settle after
    // it elapses. Cap = payoutWindowBps of the live bankroll.
    uint32 public payoutWindow = 1 days;
    uint16 public payoutWindowBps = 2000; // 20% of bankroll per window
    uint16 public constant MAX_PAYOUT_WINDOW_BPS = 10_000; // owner can't exceed 100%
    uint256 public payoutWindowStart;
    uint256 public payoutInWindow;

    // ─── events ──────────────────────────────────────────────────────────────
    event Deposit(address indexed player, uint256 amount);
    event WithdrawRequested(address indexed player, uint256 amount, uint256 readyAt);
    event WithdrawCancelled(address indexed player);
    event Withdraw(address indexed player, uint256 amount);
    event WithdrawDelaySet(uint32 withdrawDelay);
    event BankrollFunded(address indexed from, uint256 amount, uint256 bankroll);
    event BankrollDefunded(address indexed to, uint256 amount, uint256 bankroll);
    event NetSettled(
        bytes32 indexed id,
        address indexed player,
        uint256 sumStake,
        uint256 sumPayout,
        uint256 betCount,
        bytes32 merkleRoot,
        uint64 epoch,
        int256 net
    );
    event OperatorSet(address operator);
    event GuardianSet(address guardian);
    event CapSet(uint16 capBps);
    event PayoutWindowSet(uint32 window, uint16 bps);
    event PausedSet(bool paused);
    event OwnershipTransferred(address indexed from, address indexed to);

    modifier onlyOwner() {
        require(msg.sender == owner, "!owner");
        _;
    }
    modifier nonReentrant() {
        require(_lock == 1, "reentrant");
        _lock = 2;
        _;
        _lock = 1;
    }
    modifier notPaused() {
        require(!paused, "paused");
        _;
    }

    constructor(address token_, address operator_) {
        require(token_ != address(0) && operator_ != address(0), "zero addr");
        token = IERC20(token_);
        owner = msg.sender;
        operator = operator_;
        payoutWindowStart = block.timestamp;
    }

    // ─── dynamic cap ─────────────────────────────────────────────────────────
    /// @notice The largest payout any single settlement may pay out, right now.
    function maxPayout() public view returns (uint256) {
        return (bankroll * capBps) / 10_000;
    }

    // ─── custody ─────────────────────────────────────────────────────────────
    function deposit(uint256 amount) external nonReentrant notPaused {
        uint256 received = _pull(msg.sender, amount); // fee-on-transfer safe
        credits[msg.sender] += received;
        emit Deposit(msg.sender, received);
    }

    /// @notice Step 1: arm a withdrawal. Funds stay in `credits` (and are still
    ///         reachable by `settle`'s loss path) until the timelock matures, so
    ///         any in-flight bet resolves first. Re-arming overwrites the request.
    function requestWithdraw(uint256 amount) external {
        require(amount > 0, "zero");
        require(credits[msg.sender] >= amount, "insufficient credit");
        pendingWithdraw[msg.sender] = amount;
        uint256 readyAt = block.timestamp + withdrawDelay;
        withdrawableAt[msg.sender] = readyAt;
        emit WithdrawRequested(msg.sender, amount, readyAt);
    }

    /// @notice Step 2: execute the matured request. Pays out min(requested, credit)
    ///         and clears the request — it claims what you actually still hold, so a
    ///         loss that settled during the timelock simply reduces the payout
    ///         (the player can't escape a mid-flight loss) rather than bricking the
    ///         claim. Off-chain the requested amount is reserved from betting, so in
    ///         normal play this pays the full request.
    function withdraw() external nonReentrant notPaused {
        uint256 req = pendingWithdraw[msg.sender];
        require(req > 0, "no request");
        require(block.timestamp >= withdrawableAt[msg.sender], "timelocked");
        uint256 bal = credits[msg.sender];
        uint256 amount = req <= bal ? req : bal; // claim what's available
        require(amount > 0, "nothing to claim");
        pendingWithdraw[msg.sender] = 0;
        withdrawableAt[msg.sender] = 0;
        credits[msg.sender] = bal - amount;
        require(token.transfer(msg.sender, amount), "transfer fail");
        emit Withdraw(msg.sender, amount);
    }

    /// @notice Drop a pending withdrawal request (e.g. to keep playing the reserved
    ///         funds). No funds move — nothing was escrowed on-chain.
    function cancelWithdraw() external {
        require(pendingWithdraw[msg.sender] > 0, "no request");
        pendingWithdraw[msg.sender] = 0;
        withdrawableAt[msg.sender] = 0;
        emit WithdrawCancelled(msg.sender);
    }

    // ─── bankroll ────────────────────────────────────────────────────────────
    function fundBankroll(uint256 amount) external nonReentrant {
        uint256 received = _pull(msg.sender, amount);
        bankroll += received;
        emit BankrollFunded(msg.sender, received, bankroll);
    }

    function defundBankroll(uint256 amount, address to) external onlyOwner nonReentrant {
        require(bankroll >= amount, "exceeds bankroll");
        bankroll -= amount;
        require(token.transfer(to, amount), "transfer fail");
        emit BankrollDefunded(to, amount, bankroll);
    }

    // ─── net / epoch settlement (the operator is the sole settlement authority) ─
    /// @notice Apply the NET result of an epoch of bets — the ONLY settlement entry
    ///         point (fully custodial; the operator submits directly). `sumStake`/
    ///         `sumPayout` are the totals over the epoch; only the net moves.
    ///         `merkleRoot` commits to the per-bet leaves (verified off-chain — see
    ///         onchain/VERIFY.md). A single bet is just an epoch of one.
    ///         Guardrails CONTAIN a compromised operator (they don't prevent it):
    ///         the 1% CAP, the rolling CIRCUIT BREAKER, PAUSE (owner/guardian), and
    ///         the cold-owner-only `defundBankroll`. The off-chain accumulator
    ///         flushes before the cap/breaker bite, so a legitimate epoch never reverts.
    function settleNet(
        bytes32 id,
        address player,
        uint256 sumStake,
        uint256 sumPayout,
        uint256 betCount,
        bytes32 merkleRoot,
        uint64 epoch
    ) external notPaused {
        require(msg.sender == operator, "!operator");
        require(!settled[id], "replay");
        settled[id] = true;

        if (sumPayout >= sumStake) {
            uint256 net = sumPayout - sumStake; // player net win, paid from bankroll
            require(net <= maxPayout(), "net > cap");
            _accruePayout(net); // circuit breaker
            require(bankroll >= net, "bankroll short");
            bankroll -= net;
            credits[player] += net;
            emit NetSettled(id, player, sumStake, sumPayout, betCount, merkleRoot, epoch, int256(net));
        } else {
            uint256 net = sumStake - sumPayout; // player net loss, accrues to bankroll
            require(credits[player] >= net, "credit short");
            credits[player] -= net;
            bankroll += net;
            emit NetSettled(id, player, sumStake, sumPayout, betCount, merkleRoot, epoch, -int256(net));
        }
    }

    // ─── circuit breaker: bound net win payouts per rolling window ─────────────
    /// @dev Accrue a net win payout; revert if it breaches the window cap. Bounds
    ///      how much a compromised operator can extract before the window resets
    ///      or the owner/guardian pauses. A fixed window (resets on first call
    ///      after it elapses), capped at payoutWindowBps of the live bankroll.
    function _accruePayout(uint256 net) internal {
        if (block.timestamp >= payoutWindowStart + payoutWindow) {
            payoutWindowStart = block.timestamp;
            payoutInWindow = 0;
        }
        payoutInWindow += net;
        require(payoutInWindow <= (bankroll * payoutWindowBps) / 10_000, "payout window cap");
    }

    /// @notice The remaining net win payout allowed in the current window, now.
    function payoutWindowRemaining() external view returns (uint256) {
        uint256 used = block.timestamp >= payoutWindowStart + payoutWindow ? 0 : payoutInWindow;
        uint256 cap = (bankroll * payoutWindowBps) / 10_000;
        return cap > used ? cap - used : 0;
    }

    // ─── admin ───────────────────────────────────────────────────────────────
    function setOperator(address op) external onlyOwner {
        require(op != address(0), "zero addr");
        operator = op;
        emit OperatorSet(op);
    }

    function setGuardian(address g) external onlyOwner {
        guardian = g; // may be address(0) to disable
        emit GuardianSet(g);
    }

    function setCapBps(uint16 bps) external onlyOwner {
        require(bps > 0 && bps <= MAX_CAP_BPS, "cap range");
        capBps = bps;
        emit CapSet(bps);
    }

    function setWithdrawDelay(uint32 d) external onlyOwner {
        require(d >= MIN_WITHDRAW_DELAY && d <= MAX_WITHDRAW_DELAY, "delay range");
        withdrawDelay = d;
        emit WithdrawDelaySet(d);
    }

    function setPayoutWindow(uint32 window, uint16 bps) external onlyOwner {
        require(window >= 1 hours && window <= 30 days, "window range");
        require(bps > 0 && bps <= MAX_PAYOUT_WINDOW_BPS, "bps range");
        payoutWindow = window;
        payoutWindowBps = bps;
        emit PayoutWindowSet(window, bps);
    }

    /// @notice Emergency freeze of settlement AND withdrawals. Owner or guardian
    ///         (a hot key / monitoring bot) can trip it for fast incident response.
    function pause() external {
        require(msg.sender == owner || msg.sender == guardian, "!pauser");
        paused = true;
        emit PausedSet(true);
    }

    /// @notice Only the (cold) owner can lift the freeze.
    function unpause() external onlyOwner {
        paused = false;
        emit PausedSet(false);
    }

    function transferOwnership(address to) external onlyOwner {
        require(to != address(0), "zero addr");
        emit OwnershipTransferred(owner, to);
        owner = to;
    }

    // ─── helpers ─────────────────────────────────────────────────────────────
    /// @dev Pull tokens and return the amount actually received (handles fee-on-transfer).
    function _pull(address from, uint256 amount) internal returns (uint256) {
        uint256 before = token.balanceOf(address(this));
        require(token.transferFrom(from, address(this), amount), "transferFrom fail");
        return token.balanceOf(address(this)) - before;
    }
}
Chain explorer3975msChain node72ms