// SPDX-License-Identifier: UNLICENSED // Proprietary - (c) 2026 Durian (durianfun). All rights reserved. Not open source; for Durianfun + authorized integrations only. See /LICENSE. pragma solidity 0.8.24; import "../EntropyConsumer.sol"; /// @title CoinFlipHouse /// @notice The reference money game, KEEPER-FREE, showing why a losing player cannot dodge. /// /// Two properties make "no keeper" safe for real value: /// 1. ESCROW AT BET TIME — the stake enters the house in flip(); a loser already paid, so /// refusing to settle returns nothing. /// 2. PERMISSIONLESS SETTLE — anyone reveals: a winner (or the next player's self-crank, or /// anyone via forfeitExpired) settles. The loser's cooperation is never required. /// On expiry the stake is FORFEIT to the house — never refunded, never re-rolled. /// /// Payouts use a PULL ledger: the callback only updates accounting (never sends ETH), so a /// winner whose receiver reverts can never strand `locked` or brick settlement (audit fix). /// The winner pulls with claim(). Demo simplifications a production house must harden: FIFO /// queue (use a ring buffer), fixed 2× payout, MAX_BET as the only per-block aggregate guard. contract CoinFlipHouse is EntropyConsumer { uint32 public constant CB_GAS = 150_000; // SIZED PER DEPLOYMENT, NOT HARDCODED. A validator that produces the reveal block can grind/withhold // its blockhash to win the bets resting on it; for a 2× edge-0 game the grinder's gain ≈ the // AGGREGATE stake on that block. So you MUST set `perBlockCap` so that gain stays well under a KUB // block reward (rule of thumb: perBlockCap < block_reward / 10). On KUB the block reward is small, // so this is a SMALL number — do not copy a placeholder. `maxBet` is the per-bet ceiling. uint256 public immutable maxBet; uint256 public immutable perBlockCap; struct Bet { address player; uint96 amount; bool heads; bool open; } mapping(uint256 => Bet) public bets; // entropy id => bet mapping(address => uint256) public winnings; // pull ledger for winners mapping(uint256 => uint256) public committedAtBlock; // revealBlock => total stake resting on it uint256[] public queue; // pending ids, FIFO uint256 public head; // next queue index to self-crank uint256 public locked; // reserved for OPEN bets (2× each) uint256 public owed; // reserved for unclaimed winnings uint256 private _nonce; event BetPlaced(uint256 indexed id, address indexed player, uint96 amount, bool heads); event BetSettled(uint256 indexed id, address indexed player, bool won, uint256 payout); event BetForfeited(uint256 indexed id, address indexed player, uint96 amount); event Claimed(address indexed player, uint256 amount); /// @param _maxBet per-bet ceiling. /// @param _perBlockCap aggregate stake allowed on one reveal block (set < block_reward/10 — see above). constructor(address e, uint256 _maxBet, uint256 _perBlockCap) payable EntropyConsumer(e) { require(_maxBet > 0 && _perBlockCap >= _maxBet, "bad caps"); maxBet = _maxBet; perBlockCap = _perBlockCap; } /// @notice One signature: escrow the stake, pay the protocol fee, lock the roll to a future block. /// Self-cranks the oldest pending bet first, so the previous player settles automatically. function flip(bool heads) external payable { uint256 stake = msg.value; require(stake > 0 && stake <= maxBet, "bad bet"); _crankOne(); // keeper-free: settle/forfeit the oldest pending bet in this same tx // AGGREGATE per-block cap (anti block-producer-bias): bound the total value resting on the // reveal block this bet will use. revealBlock = block.number + 1 (matches the core). // NOTE (production): also consider a per-PLAYER sub-cap so one actor can't fill the whole block // and grief others out; here the only effect of hitting the cap is a revert (no escrow taken). uint256 revealBlock = block.number + 1; committedAtBlock[revealBlock] += stake; require(committedAtBlock[revealBlock] <= perBlockCap, "block cap"); uint256 f = entropy.fee(CB_GAS); // solvency: balance (incl. this stake) covers all open-bet reserves + unclaimed wins + this // bet's 2x + the fee about to leave. require(address(this).balance >= locked + owed + stake * 2 + f, "bankroll"); bytes32 seed = keccak256(abi.encodePacked(msg.sender, block.number, _nonce++)); uint256 id = _roll(seed, CB_GAS); // via SDK base -> sets known[id] (callback provenance) + forwards fee bets[id] = Bet(msg.sender, uint96(stake), heads, true); locked += stake * 2; queue.push(id); emit BetPlaced(id, msg.sender, uint96(stake), heads); } /// @dev Pure accounting — NEVER sends ETH, so it cannot revert on a hostile receiver. This keeps /// `open`/`locked` monotonic regardless of who won. function _onRandomness(uint256 id, uint256 rng) internal override { Bet memory b = bets[id]; if (!b.open) return; bets[id].open = false; uint256 reserve = uint256(b.amount) * 2; locked -= reserve; bool won = ((rng & 1) == 0) == b.heads; // heads == even if (won) { winnings[b.player] += reserve; // move reserve from open-bet to claimable owed += reserve; } // loss: stake stays in the house bankroll emit BetSettled(id, b.player, won, won ? reserve : 0); } /// @notice Winners pull their payout (CEI; a failing transfer only affects the caller). function claim() external { uint256 w = winnings[msg.sender]; require(w != 0, "nothing"); winnings[msg.sender] = 0; owed -= w; (bool ok, ) = msg.sender.call{value: w}(""); require(ok, "claim failed"); emit Claimed(msg.sender, w); } /// @notice Anyone can finalize an expired, unsettled bet: the stake is forfeit to the house. function forfeitExpired(uint256 id) external { require(entropy.status(id) == DurianEntropyV2.Status.EXPIRED, "not expired"); _forfeit(id); } function _crankOne() internal { if (head >= queue.length) return; uint256 id = queue[head]; DurianEntropyV2.Status st = entropy.status(id); if (st == DurianEntropyV2.Status.READY) { head++; _tryReveal(id); } else if (st == DurianEntropyV2.Status.EXPIRED) { head++; _forfeit(id); } else if (st == DurianEntropyV2.Status.FULFILLED) { head++; } // already settled // WAITING: leave at head, retry next flip } function _forfeit(uint256 id) private { Bet memory b = bets[id]; if (!b.open) return; bets[id].open = false; locked -= uint256(b.amount) * 2; emit BetForfeited(id, b.player, b.amount); // stake kept by the house, never refunded } }