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
- Creator
- 0xe0036a656e…08129cF2FE
- Creation tx
- 0x1608c35f6d…c5b8f46525
Token
- Name
- —
- Symbol
- —
- Decimals
- —
- Holders
- 0
- Market
- View token page →
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