// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; /// @title VarianceVault /// @notice A house-banked variance exchange. LPs deposit USDC to earn the house edge as yield; /// bettors place parametric bets against the pool with user-chosen odds. Single-bet /// payout is capped as a percentage of LP equity so no single event can drain the pool. /// @dev Randomness uses commit + future-blockhash. A bet placed in block N is settled from the /// blockhash of block N+SETTLE_DELAY_BLOCKS by anyone calling settle(). Known weakness: /// the proposer of the settlement block can grind by censoring their own block, paying a /// missed-slot cost to re-roll. Acceptable while max payouts are small; plan to swap the /// entropy source for Pyth Entropy / drand in v1.1 without changing the two-phase flow. /// /// Settle vs. void is bound to actual blockhash availability (the EVM BLOCKHASH opcode /// returns non-zero only for the last 256 blocks), NOT to a hardcoded block count. A /// bettor cannot free-roll by delaying a losing settlement past an arbitrary cutoff. contract VarianceVault is Initializable, ERC20Upgradeable, Ownable2StepUpgradeable, PausableUpgradeable, ReentrancyGuardUpgradeable, UUPSUpgradeable { using SafeERC20 for IERC20; using EnumerableSet for EnumerableSet.AddressSet; // --------------------------------------------------------------------- // Constants // --------------------------------------------------------------------- uint256 public constant BPS = 10_000; /// @notice Number of blocks after a bet commit before it can be settled. /// @dev 2 blocks on HyperEVM ≈ 2s. Must be >= 1; larger = stronger against reorgs /// but reorgs are impossible under HyperBFT finality, so 2 is safe and snappy. uint256 public constant SETTLE_DELAY_BLOCKS = 2; /// @notice The number of past blocks whose hash the EVM `blockhash` opcode can still /// return. A settlement block older than this returns `bytes32(0)`. We use this /// as the exact boundary between `settle()` and `voidBet()`: `settle()` works as /// long as the blockhash is available, `voidBet()` only once it isn't. Binding /// to the opcode's true behavior (rather than a smaller hardcoded expiry) /// prevents a bettor from free-rolling by deliberately delaying a losing bet /// past an artificial cutoff. uint256 public constant BLOCKHASH_WINDOW = 256; uint256 public constant WITHDRAW_COOLDOWN = 24 hours; /// @notice Hard caps on owner-settable parameters. The owner can adjust within these bounds /// but cannot set them arbitrarily high post-deployment. Provides LP guarantees. /// @dev MAX_FEE_BPS is 0 on mainnet: this is the "home poker game" guarantee — no /// flow fees, ever. The owner can never re-enable deposit/withdraw fees because /// setFees() bounds against this constant. The 1.5% LP edge (edgeBps) is /// separate and is how LPs make EV; it is not a house cut. uint256 public constant MAX_FEE_BPS = 0; // permanently no house flow fees uint256 public constant MAX_EDGE_BPS = 500; // 5% max house edge uint256 public constant MAX_PAYOUT_CAP_BPS = 1000; // 10% max single-bet LP drawdown /// @notice Hard ceiling on the per-user deposit cap. Owner can raise `depositCap` up /// to this value over time as the system earns trust ($100 → $1K → $10K → ... /// → $1M), but cannot exceed it even via direct call. Pre-committed /// concentration guarantee for LPs/bettors during the allowlisted phase. uint256 public constant MAX_DEPOSIT_CAP = 1_000_000 * 1e6; // $1M USDC (6 decimals) /// @notice Permanently-locked "dead" shares minted on first deposit, and an absolute /// floor on the initial deposit. Together they defeat the classic ERC-4626 /// first-depositor inflation attack by forcing the attacker to commit real /// capital (>= MIN_INITIAL_DEPOSIT) AND guaranteeing a large baseline share /// count so later deposits can never round to zero against a manipulated price. uint256 public constant DEAD_SHARES = 100_000; // 0.1 USDC worth at genesis uint256 public constant MIN_INITIAL_DEPOSIT = 1_000_000; // 1 USDC net // --------------------------------------------------------------------- // Storage // --------------------------------------------------------------------- IERC20 public asset; // USDC (6 decimals on HyperEVM) address public treasury; // receives deposit + withdrawal fees uint256 public depositFeeBps; uint256 public withdrawFeeBps; uint256 public edgeBps; uint256 public maxPayoutBps; // max LP drawdown from one bet, as bps of netAssets() /// @notice Sum over all unsettled bets of the maximum LP drawdown they could cause /// (i.e. grossPayout, since stake is already in the pool balance). uint256 public outstandingLiability; enum BetStatus { Open, Won, Lost, Voided } /// @notice Maximum byte length for a win or loss message attached to a bet. uint256 public constant MAX_MESSAGE_BYTES = 200; struct Bet { address bettor; uint128 stake; uint128 grossPayout; // amount paid to bettor on win uint64 winProbBps; uint64 commitBlock; BetStatus status; bool fromPool; // true = betFromPool, settle auto-redeposits uint128 sharesBurned; // shares burned in betFromPool (used for re-minting on win) bytes32 winMsgHash; // keccak256 of win message (0 = no message) bytes32 lossMsgHash; // keccak256 of loss message (0 = no message) uint128 snapshotSupply; // totalSupply() at settlement (pre-mint/burn) uint128 snapshotAssets; // netAssets() at settlement (pre-transfer) } mapping(uint256 => Bet) public bets; uint256 public nextBetId; /// @notice Tracks cumulative net USDC deposited per user (deposits minus withdrawals). /// Used by the dashboard to compute LP yield = vPoolValue - costBasis. mapping(address => uint256) public costBasis; /// @notice Tracks cumulative fees paid by each user (deposit fees + withdrawal fees). mapping(address => uint256) public totalFeesPaid; /// @notice Tracks cumulative net betting P&L per user in USDC (6 decimals). /// Positive = net winner, negative = net loser. /// Updated atomically in settle() with exact values. mapping(address => int256) public bettingPnl; /// @notice Tracks total USDC wagered per user across all bets. mapping(address => uint256) public totalWagered; struct WithdrawRequest { uint256 shares; uint256 readyAt; } mapping(address => WithdrawRequest) public withdrawRequests; /// @notice Dedup store for bet messages. Key = keccak256(bytes(message)), /// value = the message text. Each unique message is stored once; /// subsequent bets with the same message just reference the hash. /// Readable via the auto-generated `messages(bytes32)` getter so /// the frontend can resolve any hash without scanning events. mapping(bytes32 => string) public messages; // --------------------------------------------------------------------- // Events // --------------------------------------------------------------------- event Deposited(address indexed user, uint256 assets, uint256 shares, uint256 fee); event WithdrawRequested(address indexed user, uint256 shares, uint256 readyAt); event WithdrawCancelled(address indexed user, uint256 shares); event Withdrawn(address indexed user, uint256 assetsOut, uint256 sharesBurned, uint256 fee); event BetPlaced( uint256 indexed betId, address indexed bettor, uint256 stake, uint256 winProbBps, uint256 grossPayout, uint256 commitBlock, bytes32 winMsgHash, bytes32 lossMsgHash ); /// @notice Emitted exactly once per unique message, the first time it appears /// in a bet. The frontend builds a hash→text cache from these events /// so repeated messages don't bloat log storage. event MessageStored(bytes32 indexed hash, string message); event BetSettled(uint256 indexed betId, bool won, uint256 payout, uint256 roll); event BetVoided(uint256 indexed betId, uint256 stakeRefunded); event TreasuryUpdated(address newTreasury); event FeesUpdated(uint256 depositFeeBps, uint256 withdrawFeeBps); event EdgeUpdated(uint256 edgeBps); event MaxPayoutUpdated(uint256 maxPayoutBps); event DepositCapUpdated(uint256 newCap); event AllowlistEnabledUpdated(bool enabled); event AllowedUpdated(address indexed user, bool isAllowed); // --------------------------------------------------------------------- // Initialization // --------------------------------------------------------------------- /// @custom:oz-upgrades-unsafe-allow constructor constructor() { _disableInitializers(); } function initialize( address _asset, address _owner, address _treasury, uint256 _depositFeeBps, uint256 _withdrawFeeBps, uint256 _edgeBps, uint256 _maxPayoutBps ) external initializer { __ERC20_init("Variance Pool Share", "vPOOL"); __Ownable_init(_owner); __Ownable2Step_init(); __Pausable_init(); __ReentrancyGuard_init(); __UUPSUpgradeable_init(); require(_asset != address(0), "zero asset"); require(_treasury != address(0), "zero treasury"); require(_depositFeeBps <= MAX_FEE_BPS, "deposit fee too high"); require(_withdrawFeeBps <= MAX_FEE_BPS, "withdraw fee too high"); require(_edgeBps <= MAX_EDGE_BPS, "edge too high"); require(_maxPayoutBps > 0 && _maxPayoutBps <= MAX_PAYOUT_CAP_BPS, "bad payout cap"); asset = IERC20(_asset); treasury = _treasury; depositFeeBps = _depositFeeBps; withdrawFeeBps = _withdrawFeeBps; edgeBps = _edgeBps; maxPayoutBps = _maxPayoutBps; } // --------------------------------------------------------------------- // Share accounting (conservative: in-flight bet liability is subtracted) // --------------------------------------------------------------------- /// @notice LP equity: the vault's USDC balance minus all worst-case bet payouts. /// New depositors buy in at this price, so they can never skim EV from pending bets. function netAssets() public view returns (uint256) { uint256 bal = asset.balanceOf(address(this)); if (bal <= outstandingLiability) return 0; return bal - outstandingLiability; } function convertToAssets(uint256 shares) public view returns (uint256) { uint256 supply = totalSupply(); if (supply == 0) return 0; return (shares * netAssets()) / supply; } /// @notice Max single-bet LP drawdown. Enforced on placeBet. function maxBetPayout() public view returns (uint256) { return (netAssets() * maxPayoutBps) / BPS; } // --------------------------------------------------------------------- // Holder enumeration // --------------------------------------------------------------------- /// @notice Number of unique addresses currently holding vPOOL shares. function holderCount() external view returns (uint256) { return _holders.length(); } /// @notice Paginated list of holder addresses. Returns up to `limit` /// addresses starting from `offset`. Gas cost is O(limit) — the /// caller controls the page size, so this works at any holder count. /// @param offset Zero-based index to start from. /// @param limit Maximum number of addresses to return. /// @return addrs Slice of the holder set. Length may be < limit if offset /// is near the end of the set. function getHolders(uint256 offset, uint256 limit) external view returns (address[] memory addrs) { uint256 len = _holders.length(); if (offset >= len) return new address[](0); uint256 end = offset + limit; if (end > len) end = len; uint256 count = end - offset; addrs = new address[](count); for (uint256 i = 0; i < count; i++) { addrs[i] = _holders.at(offset + i); } } // --------------------------------------------------------------------- // LP side // --------------------------------------------------------------------- function deposit(uint256 assets) external nonReentrant whenNotPaused returns (uint256 shares) { require(assets > 0, "zero deposit"); uint256 fee = (assets * depositFeeBps) / BPS; uint256 net = assets - fee; require(net > 0, "net zero"); // Mainnet gates: allowlist + per-user deposit cap. The cap is on principal at // risk in the system (share value + open placeBet stakes); winnings are not // counted, so a user who busts via betting can redeposit up to cap freely. if (allowlistEnabled) require(allowed[msg.sender], "not allowed"); require(exposureOf(msg.sender) + net <= depositCap, "exceeds cap"); // Pull full amount, skim fee to treasury asset.safeTransferFrom(msg.sender, address(this), assets); if (fee > 0) asset.safeTransfer(treasury, fee); uint256 supply = totalSupply(); if (supply == 0) { // First-depositor inflation-attack mitigation: require a real minimum // initial deposit, lock DEAD_SHARES permanently at 0xdead. require(net >= MIN_INITIAL_DEPOSIT, "initial deposit too small"); shares = net - DEAD_SHARES; _mint(address(0xdead), DEAD_SHARES); } else { // netAssets() already reflects the incoming `net`; back it out to price // against the pre-deposit pool. uint256 netBefore = netAssets() - net; require(netBefore > 0, "vault insolvent"); shares = (net * supply) / netBefore; } require(shares > 0, "zero shares"); _mint(msg.sender, shares); costBasis[msg.sender] += net; if (fee > 0) totalFeesPaid[msg.sender] += fee; emit Deposited(msg.sender, assets, shares, fee); } /// @notice Deposit on behalf of another address. USDC is pulled from msg.sender, /// shares are minted to `recipient`. Used by the faucet to onboard users /// in a single server-side transaction. function depositFor(address recipient, uint256 assets) external nonReentrant whenNotPaused returns (uint256 shares) { require(assets > 0, "zero deposit"); require(recipient != address(0), "zero recipient"); uint256 fee = (assets * depositFeeBps) / BPS; uint256 net = assets - fee; require(net > 0, "net zero"); // Both caller and recipient must be allowlisted (caller is funding, recipient // is gaining principal). Cap applies to recipient — they're the one who'll own // the resulting shares. This blocks a griefing vector where one allowlisted // user fills another's cap headroom against their will. if (allowlistEnabled) { require(allowed[msg.sender], "not allowed"); require(allowed[recipient], "recipient not allowed"); } require(exposureOf(recipient) + net <= depositCap, "exceeds cap"); asset.safeTransferFrom(msg.sender, address(this), assets); if (fee > 0) asset.safeTransfer(treasury, fee); uint256 supply = totalSupply(); if (supply == 0) { require(net >= MIN_INITIAL_DEPOSIT, "initial deposit too small"); shares = net - DEAD_SHARES; _mint(address(0xdead), DEAD_SHARES); } else { uint256 netBefore = netAssets() - net; require(netBefore > 0, "vault insolvent"); shares = (net * supply) / netBefore; } require(shares > 0, "zero shares"); _mint(recipient, shares); costBasis[recipient] += net; if (fee > 0) totalFeesPaid[recipient] += fee; emit Deposited(recipient, assets, shares, fee); } /// @notice Begin a withdrawal. Shares are escrowed in the contract and remain outstanding /// (i.e. continue absorbing P&L) until `withdraw()` is called after the cooldown. /// This is intentional — it means pending-withdraw LPs cannot lock in a price and /// free-ride off other LPs' bet exposure during the 24h window. function requestWithdraw(uint256 shares) external nonReentrant { require(shares > 0, "zero shares"); require(balanceOf(msg.sender) >= shares, "insufficient shares"); require(withdrawRequests[msg.sender].shares == 0, "request pending"); _transfer(msg.sender, address(this), shares); uint256 readyAt = block.timestamp + WITHDRAW_COOLDOWN; withdrawRequests[msg.sender] = WithdrawRequest({shares: shares, readyAt: readyAt}); emit WithdrawRequested(msg.sender, shares, readyAt); } function cancelWithdraw() external nonReentrant { WithdrawRequest memory req = withdrawRequests[msg.sender]; require(req.shares > 0, "no request"); delete withdrawRequests[msg.sender]; _transfer(address(this), msg.sender, req.shares); emit WithdrawCancelled(msg.sender, req.shares); } function withdraw() external nonReentrant whenNotPaused returns (uint256 assetsOut) { WithdrawRequest memory req = withdrawRequests[msg.sender]; require(req.shares > 0, "no request"); require(block.timestamp >= req.readyAt, "cooldown"); delete withdrawRequests[msg.sender]; uint256 gross = convertToAssets(req.shares); _burn(address(this), req.shares); // Must still cover all in-flight bet liability after this withdrawal. require( asset.balanceOf(address(this)) >= gross + outstandingLiability, "would undercollateralize" ); uint256 fee = (gross * withdrawFeeBps) / BPS; assetsOut = gross - fee; if (fee > 0) { asset.safeTransfer(treasury, fee); totalFeesPaid[msg.sender] += fee; } asset.safeTransfer(msg.sender, assetsOut); uint256 cb = costBasis[msg.sender]; costBasis[msg.sender] = cb > gross ? cb - gross : 0; emit Withdrawn(msg.sender, assetsOut, req.shares, fee); } // --------------------------------------------------------------------- // Message helpers // --------------------------------------------------------------------- /// @dev Hash, dedup-store, and return the hash of a bet message. /// Empty messages return bytes32(0) — the sentinel for "no message." function _storeMsg(string calldata text) internal returns (bytes32) { uint256 len = bytes(text).length; if (len == 0) return bytes32(0); require(len <= MAX_MESSAGE_BYTES, "msg too long"); bytes32 h = keccak256(bytes(text)); if (bytes(messages[h]).length == 0) { messages[h] = text; emit MessageStored(h, text); } return h; } // --------------------------------------------------------------------- // Bettor side // --------------------------------------------------------------------- /// @notice Place a bet using your pool position instead of wallet USDC. /// /// Shares are ESCROWED to the contract (not burned) so that totalSupply /// doesn't change and the share price is unaffected for all holders. /// On win: escrowed shares returned + extra shares minted for net profit. /// On loss: escrowed shares are burned (now the pool absorbs the loss). /// On void: escrowed shares returned exactly. /// /// Liability reservation: because the stake is paid in shares (not USDC) /// and the bet is settled by minting or burning shares (not by moving /// USDC), the only way this bet can change LP equity is through the /// lpDrawdown = grossPayout - stake term. We reserve exactly that much /// as outstandingLiability — NOT the full grossPayout — so netAssets() /// reflects the true worst-case LP equity during the bet. Reserving the /// full grossPayout (as an earlier version did) under-reported netAssets /// by `stake`, letting whales DoS the pool with a single near-full-stake /// fromPool bet and letting depositors sandwich open bets for free EV. /// /// @param stake USDC-equivalent amount to risk. /// @param winProbBps Desired win probability in basis points; must be in (0, BPS). /// @param minGrossPayout Slippage guard: revert if the contract's computed /// grossPayout is below this floor. Protects the bettor /// against an owner front-run raising `edgeBps` between /// quote and execution. Pass 0 to disable. /// @return betId Opaque handle used to settle or void. function betFromPool( uint256 stake, uint256 winProbBps, uint256 minGrossPayout, string calldata winMessage, string calldata lossMessage ) external nonReentrant whenNotPaused returns (uint256 betId) { require(stake > 0, "zero stake"); require(winProbBps > 0 && winProbBps < BPS, "bad prob"); if (allowlistEnabled) require(allowed[msg.sender], "not allowed"); // No cap check here: this bet's stake comes from the user's existing shares, // so total exposure (shareValue + outstandingBetStake) is preserved across the // call. Shares move to escrow (balance drops by stake-equivalent) while // outstandingBetStake increases by exactly that stake — net zero. // How many shares is `stake` worth? uint256 supply = totalSupply(); uint256 net = netAssets(); require(net > 0, "vault insolvent"); uint256 sharesToEscrow = (stake * supply) / net; if ((sharesToEscrow * net) / supply < stake) sharesToEscrow++; require(balanceOf(msg.sender) >= sharesToEscrow, "insufficient shares"); uint256 grossPayout = (stake * (BPS - edgeBps)) / winProbBps; require(grossPayout > stake, "unprofitable odds"); require(grossPayout >= minGrossPayout, "slippage"); uint256 lpDrawdown = grossPayout - stake; require(lpDrawdown <= maxBetPayout(), "exceeds max payout"); // Escrow shares to the contract — totalSupply unchanged, share price unchanged _transfer(msg.sender, address(this), sharesToEscrow); // Reserve only the true LP drawdown (see function-level docs above). outstandingLiability += lpDrawdown; bytes32 wh = _storeMsg(winMessage); bytes32 lh = _storeMsg(lossMessage); betId = nextBetId++; bets[betId] = Bet({ bettor: msg.sender, stake: uint128(stake), grossPayout: uint128(grossPayout), winProbBps: uint64(winProbBps), commitBlock: uint64(block.number), status: BetStatus.Open, fromPool: true, sharesBurned: uint128(sharesToEscrow), // reused field: escrowed shares winMsgHash: wh, lossMsgHash: lh, snapshotSupply: 0, snapshotAssets: 0 }); totalWagered[msg.sender] += stake; outstandingBetStake[msg.sender] += stake; emit BetPlaced(betId, msg.sender, stake, winProbBps, grossPayout, block.number, wh, lh); } /// @notice Place a parametric bet against the pool. /// @param stake USDC the bettor is risking. /// @param winProbBps Desired win probability in basis points; must be in (0, BPS). /// Gross payout on win = stake * (BPS - edgeBps) / winProbBps. /// @param minGrossPayout Slippage guard: revert if the contract's computed /// grossPayout is below this floor. Protects the bettor /// against an owner front-run raising `edgeBps` between /// quote and execution. Pass 0 to disable. /// @return betId Opaque handle used to settle or void. function placeBet( uint256 stake, uint256 winProbBps, uint256 minGrossPayout, string calldata winMessage, string calldata lossMessage ) external nonReentrant whenNotPaused returns (uint256 betId) { require(stake > 0, "zero stake"); require(winProbBps > 0 && winProbBps < BPS, "bad prob"); // Mainnet gates: allowlist + cap. Stake comes from external wallet, so it's // fresh principal entering the system and counts toward the user's cap. if (allowlistEnabled) require(allowed[msg.sender], "not allowed"); require(exposureOf(msg.sender) + stake <= depositCap, "exceeds cap"); // grossPayout is what the bettor receives on a win (includes their stake back). uint256 grossPayout = (stake * (BPS - edgeBps)) / winProbBps; require(grossPayout > stake, "unprofitable odds"); require(grossPayout >= minGrossPayout, "slippage"); // LP drawdown from this bet if the bettor wins, in terms of the pool's pre-bet // balance: vault pays out grossPayout, retains stake that was just paid in, so // net LP loss is (grossPayout - stake). Cap it against the pre-bet LP equity. uint256 lpDrawdown = grossPayout - stake; require(lpDrawdown <= maxBetPayout(), "exceeds max payout"); asset.safeTransferFrom(msg.sender, address(this), stake); // Reserve the full grossPayout as liability. This ensures netAssets() correctly // reflects worst-case LP equity: pre-bet balance goes up by `stake`, liability // goes up by `grossPayout`, so netAssets delta = stake - grossPayout = -lpDrawdown. outstandingLiability += grossPayout; bytes32 wh = _storeMsg(winMessage); bytes32 lh = _storeMsg(lossMessage); betId = nextBetId++; bets[betId] = Bet({ bettor: msg.sender, stake: uint128(stake), grossPayout: uint128(grossPayout), winProbBps: uint64(winProbBps), commitBlock: uint64(block.number), status: BetStatus.Open, fromPool: false, sharesBurned: 0, winMsgHash: wh, lossMsgHash: lh, snapshotSupply: 0, snapshotAssets: 0 }); totalWagered[msg.sender] += stake; outstandingBetStake[msg.sender] += stake; emit BetPlaced(betId, msg.sender, stake, winProbBps, grossPayout, block.number, wh, lh); } /// @notice Permissionless: anyone can settle any open bet once its settlement block /// has been produced AND the blockhash of that block is still on-chain. In /// practice a keeper bot will do this within a block or two, but bettors can /// always self-settle as a fallback. /// /// The only cutoff is the EVM blockhash window (256 blocks). Once a bet's /// settleBlock falls out of that window, `blockhash()` returns zero, this /// function reverts, and `voidBet()` takes over as the sole recourse. There /// is no intermediate "expired but still settleable" window — the transition /// is atomic, which eliminates the free-roll where a bettor sees a loss /// off-chain and stalls until an artificial expiry to reclaim their stake. function settle(uint256 betId) external nonReentrant { Bet storage b = bets[betId]; require(b.status == BetStatus.Open, "not open"); uint256 commitBlock = uint256(b.commitBlock); uint256 settleBlock = commitBlock + SETTLE_DELAY_BLOCKS; require(block.number > settleBlock, "too early"); bytes32 h = blockhash(settleBlock); // `blockhash(x)` returns zero if x is more than 256 blocks in the past (or still // in the future/current). The first case means we're past the window — use // voidBet instead. The second is caught by the "too early" check above. require(h != bytes32(0), "expired: use voidBet"); // Domain-separated roll. Mixing betId + bettor prevents a single block hash from // resolving every concurrent bet identically. uint256 roll = uint256(keccak256(abi.encodePacked(h, betId, b.bettor))) % BPS; bool won = roll < uint256(b.winProbBps); // Snapshot pool state BEFORE any settlement effects. These // values let the dashboard reconstruct every user's share // fraction at each historical bet without event scanning. b.snapshotSupply = uint128(totalSupply()); b.snapshotAssets = uint128(netAssets()); // For fromPool bets, only the lpDrawdown was ever reserved (see betFromPool // docs). For normal bets, the full grossPayout was reserved. Release symmetrically. outstandingLiability -= b.fromPool ? (uint256(b.grossPayout) - uint256(b.stake)) : uint256(b.grossPayout); // Free the bettor's cap headroom regardless of outcome — the stake is no longer // pending. Counted for both bet flavors so betFromPool wins/losses also release // the corresponding exposure (placeBet pairs increment in placeBet itself). outstandingBetStake[b.bettor] -= uint256(b.stake); if (won) { b.status = BetStatus.Won; // P&L = grossPayout - stake (net profit) bettingPnl[b.bettor] += int256(uint256(b.grossPayout)) - int256(uint256(b.stake)); if (b.fromPool) { _transfer(address(this), b.bettor, b.sharesBurned); uint256 netProfit = uint256(b.grossPayout) - uint256(b.stake); // Use LIVE post-release values for minting (not snapshots). // Snapshots are pre-release and stored for the dashboard only. uint256 supply = totalSupply(); uint256 net = netAssets(); uint256 profitShares = (net > 0 && supply > 0) ? (netProfit * supply) / net : netProfit; _mint(b.bettor, profitShares); } else { asset.safeTransfer(b.bettor, b.grossPayout); } emit BetSettled(betId, true, b.grossPayout, roll); } else { b.status = BetStatus.Lost; // P&L = -stake (lost the full stake) bettingPnl[b.bettor] -= int256(uint256(b.stake)); if (b.fromPool) { _burn(address(this), b.sharesBurned); } emit BetSettled(betId, false, 0, roll); } } /// @notice Refund a bet whose settle blockhash has dropped out of the EVM's 256-block /// retention window. By definition this can only fire once settle() is no /// longer possible — checking actual blockhash availability rather than a /// hardcoded block count prevents a bettor from free-rolling a losing bet /// by stalling until an artificial expiry. /// /// At 1s HyperEVM blocks this is ~4.3 minutes after commit. The keeper bot /// normally settles within a block or two, and bettors can self-settle; void /// is the fallback when both fail. function voidBet(uint256 betId) external nonReentrant { Bet storage b = bets[betId]; require(b.status == BetStatus.Open, "not open"); uint256 settleBlock = uint256(b.commitBlock) + SETTLE_DELAY_BLOCKS; // Only voidable once the blockhash is actually gone. block.number must be // strictly greater than settleBlock + BLOCKHASH_WINDOW: at that exact sum the // blockhash is still available (settleBlock is BLOCKHASH_WINDOW blocks back, // inclusive), and one block later it disappears. This matches the boundary // enforced by settle() via `blockhash(settleBlock) != 0`. require(block.number > settleBlock + BLOCKHASH_WINDOW, "settleable"); // Mirror the liability accounting from betFromPool / placeBet. outstandingLiability -= b.fromPool ? (uint256(b.grossPayout) - uint256(b.stake)) : uint256(b.grossPayout); outstandingBetStake[b.bettor] -= uint256(b.stake); b.status = BetStatus.Voided; if (b.fromPool) { // Return escrowed shares — complete no-op _transfer(address(this), b.bettor, b.sharesBurned); } else { asset.safeTransfer(b.bettor, b.stake); } emit BetVoided(betId, b.stake); } // --------------------------------------------------------------------- // Admin (path to trustlessness: renounce or move to a multisig over time) // --------------------------------------------------------------------- function pause() external onlyOwner { _pause(); } function unpause() external onlyOwner { _unpause(); } function setTreasury(address t) external onlyOwner { require(t != address(0), "zero"); treasury = t; emit TreasuryUpdated(t); } function setFees(uint256 _depositFeeBps, uint256 _withdrawFeeBps) external onlyOwner { require(_depositFeeBps <= MAX_FEE_BPS, "deposit fee too high"); require(_withdrawFeeBps <= MAX_FEE_BPS, "withdraw fee too high"); depositFeeBps = _depositFeeBps; withdrawFeeBps = _withdrawFeeBps; emit FeesUpdated(_depositFeeBps, _withdrawFeeBps); } function setEdge(uint256 _edgeBps) external onlyOwner { require(_edgeBps > 0, "zero edge"); require(_edgeBps <= MAX_EDGE_BPS, "edge too high"); edgeBps = _edgeBps; emit EdgeUpdated(_edgeBps); } function setMaxPayout(uint256 _maxPayoutBps) external onlyOwner { require(_maxPayoutBps > 0 && _maxPayoutBps <= MAX_PAYOUT_CAP_BPS, "bad payout cap"); maxPayoutBps = _maxPayoutBps; emit MaxPayoutUpdated(_maxPayoutBps); } /// @notice One-time backfill for pre-upgrade holders. Adds addresses to /// the holder set without checking balanceOf — the caller is /// responsible for passing only addresses with shares > 0. Idempotent: /// adding an address already in the set is a no-op (~2.9K gas). function backfillHolders(address[] calldata addrs) external onlyOwner { for (uint256 i = 0; i < addrs.length; i++) { _holders.add(addrs[i]); } } /// @notice Owner-tunable per-user deposit cap, bounded above by MAX_DEPOSIT_CAP. /// Ramps over the rollout: $100 → $1K → $10K → ... → $1M. function setDepositCap(uint256 _depositCap) external onlyOwner { require(_depositCap <= MAX_DEPOSIT_CAP, "cap too high"); depositCap = _depositCap; emit DepositCapUpdated(_depositCap); } /// @notice Batch add/remove addresses from the allowlist. Caller-supplied parallel /// arrays of addresses and flags. Used by the dashboard's email-auth sync job. function setAllowed(address[] calldata users, bool[] calldata flags) external onlyOwner { require(users.length == flags.length, "length mismatch"); for (uint256 i = 0; i < users.length; i++) { allowed[users[i]] = flags[i]; emit AllowedUpdated(users[i], flags[i]); } } /// @notice Master switch for allowlist gating. Flip to false at public launch — at that /// point vPOOL becomes freely transferable and deposit/bet entry points stop /// checking the allowlist mapping. function setAllowlistEnabled(bool _enabled) external onlyOwner { allowlistEnabled = _enabled; emit AllowlistEnabledUpdated(_enabled); } /// @notice One-shot v4 initializer for proxies upgraded from v3 (testnet) and called /// by the deploy script for fresh mainnet proxies right after `initialize`. /// Sets defaults: $100 deposit cap, allowlist enabled, fees zeroed (defensive /// — the v3 initialize already accepted 0 fees but pre-upgrade testnet /// storage may have non-zero values; this enforces the mainnet zero-fee /// guarantee on top of the new MAX_FEE_BPS=0 constant). function reinitializeV4() external onlyOwner reinitializer(2) { depositCap = 100 * 1e6; allowlistEnabled = true; depositFeeBps = 0; withdrawFeeBps = 0; emit DepositCapUpdated(depositCap); emit AllowlistEnabledUpdated(true); emit FeesUpdated(0, 0); } function _authorizeUpgrade(address) internal override onlyOwner {} // --------------------------------------------------------------------- // Exposure view (mainnet rollout) // --------------------------------------------------------------------- /// @notice User's total principal at risk in the system, in USDC (6 decimals). /// Sum of (a) the USDC value of their vPOOL share balance at the conservative /// netAssets() price, and (b) the stake of any open `placeBet`s they've made. /// Used by the cap-enforcement checks in deposit/depositFor/placeBet, and /// exposed for the dashboard so users can see their headroom. function exposureOf(address user) public view returns (uint256) { return convertToAssets(balanceOf(user)) + outstandingBetStake[user]; } // --------------------------------------------------------------------- // ERC-20 hook: maintain _holders set on every transfer, mint, and burn // --------------------------------------------------------------------- /// @dev Called by _mint, _burn, and _transfer. Two responsibilities: /// (1) During the allowlist phase, block peer-to-peer transfers so two /// friends-at-cap can't OTC-shuffle shares to put one above cap. /// Mint, burn, and any transfer involving address(this) (i.e. the /// contract's own escrow paths in requestWithdraw / betFromPool / /// settle return / voidBet return) are always allowed. /// (2) Keep the holder set in sync with actual balances so getHolders() /// / holderCount() are accurate — including for direct ERC-20 /// transfers (post-allowlist phase) that bypass deposit/withdraw. function _update(address from, address to, uint256 value) internal override { if ( allowlistEnabled && from != address(0) && to != address(0) && from != address(this) && to != address(this) ) { revert("transfers disabled"); } super._update(from, to, value); // Skip bookkeeping for mint-source (address(0)), dead-share sink, // and the contract itself (used for withdrawal/bet escrow). if (from != address(0) && from != address(0xdead) && from != address(this)) { if (balanceOf(from) == 0) _holders.remove(from); } if (to != address(0) && to != address(0xdead) && to != address(this)) { if (balanceOf(to) > 0) _holders.add(to); } } // --------------------------------------------------------------------- // Holder enumeration (added in upgrade v2) // --------------------------------------------------------------------- /// @notice Set of all addresses that currently hold vPOOL shares. /// Maintained automatically via the _update hook on every /// transfer, mint, and burn. Enables O(1) membership checks, /// O(1) count, and paginated enumeration via getHolders(). /// Uses 2 storage slots. EnumerableSet.AddressSet private _holders; // --------------------------------------------------------------------- // v4 storage (Stage 6: mainnet rollout — deposit cap, allowlist) // --------------------------------------------------------------------- /// @notice Per-user cap on principal at risk in the system, in USDC (6 decimals). /// Enforced anywhere new principal enters: deposit(), depositFor() (on /// recipient), and placeBet() (on the bettor). betFromPool() is intentionally /// NOT capped — its stake comes from existing shares, so total user exposure /// is preserved across the call. Owner-tunable via setDepositCap, bounded /// above by MAX_DEPOSIT_CAP. uint256 public depositCap; /// @notice Allowlist of addresses permitted to interact during the gated phase. /// Required for deposit(), depositFor() (both msg.sender and recipient), /// placeBet(), and betFromPool() while `allowlistEnabled` is true. mapping(address => bool) public allowed; /// @notice Master switch for the allowlist. True at deploy; owner flips false at /// public launch. While true, vPOOL transfers between two non-zero, /// non-contract addresses revert (anti-OTC-collusion: prevents two friends /// each at cap from shuffling shares to put one above cap). bool public allowlistEnabled; /// @notice Per-user sum of open `placeBet` stakes (wallet-funded bets). /// Counts toward the user's exposure for cap-enforcement purposes. /// Incremented in placeBet, decremented in settle/voidBet for non-fromPool /// bets. NOT incremented for betFromPool (those stakes come from shares /// already in balanceOf accounting). mapping(address => uint256) public outstandingBetStake; /// @dev Storage gap to preserve layout across upgrades. New storage MUST be added /// ABOVE this gap (i.e. before this declaration), and the gap length reduced /// by the number of slots consumed. Adding storage after the gap corrupts any /// further-derived contract's layout. /// /// v1: 50 slots. v2: 48 slots (2 consumed by _holders). /// v3: 47 slots (1 consumed by messages mapping). /// v4: 43 slots (4 consumed: depositCap, allowed, allowlistEnabled, /// outstandingBetStake). uint256[43] private __gap; }