// SPDX-License-Identifier: MIT pragma solidity 0.8.36; import { ReentrancyGuardTransient } from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import { EthereumBlockHeader } from "./libraries/EthereumBlockHeader.sol"; import { QuicknetBLS } from "./libraries/QuicknetBLS.sol"; /// @title Luckotto Edge-Capped Round Lottery /// @notice Native-ETH bankroll with ERC-20 bankroll shares and variable-payout boundary /// rounds. contract Luckotto is ERC20, ReentrancyGuardTransient { uint256 public constant MINIMUM_OWNER_SEED = 1 ether; uint256 public constant ROUND_BLOCKS = 10_000; uint256 public constant RANDAO_LOOKAHEAD_BLOCKS = 132; uint256 public constant HISTORY_WINDOW_BLOCKS = 8191; uint256 public constant QUICKNET_DELAY_SECONDS = 20 minutes; uint256 public constant WITHDRAWAL_WINDOW_BLOCKS = 260; uint256 public constant CLAIM_DELAY = 3 hours; uint256 public constant DRAIN_DELAY = 365 days; uint256 public constant EDGE_SCALE = 1 << 32; uint64 public constant MAX_FUTURE_ROUNDS = 10; uint256 private constant PROBABILITY_SCALE = 1 << 128; uint256 private constant MAINNET_CHAIN_ID = 1; uint256 private constant SEPOLIA_CHAIN_ID = 11_155_111; address private constant HISTORY_STORAGE_ADDRESS = 0x0000F90827F1C53a10cb7A02335B175320002935; bytes32 private constant DRAND_CHAIN_HASH = 0x52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971; uint256 private constant DRAND_PERIOD_SECONDS = 3; uint256 private constant DRAND_GENESIS_TIME = 1_692_803_367; bytes32 private constant PROTOCOL_DOMAIN = keccak256("LUCKOTTO_PROTOCOL_V1"); bytes32 private constant DRAW_DOMAIN = keccak256("LUCKOTTO_DRAW_V1"); bytes32 private constant CANDIDATE_DRAW_DOMAIN = keccak256("LUCKOTTO_CANDIDATE_DRAW_V1"); bytes32 private constant COVERAGE_DRAW_DOMAIN = keccak256("LUCKOTTO_COVERAGE_DRAW_V1"); bytes32 private constant RISK_DRAW_DOMAIN = keccak256("LUCKOTTO_RISK_DRAW_V1"); enum RoundState { Pending, WinnerClaimable, WinnerPaid, NoWinner, Expired, Cancelled } struct Round { RoundState state; uint64 positionCount; uint64 selectedPositionIndex; uint64 settledAt; uint128 settlementBankroll; uint128 aggregateStake; bytes32 randomnessBlockHash; } struct Position { address beneficiary; uint96 stake; uint96 targetPayout; uint32 maxEdgeQ32; uint128 rangeStart; } struct ProofInputs { bytes32 prevRandao; bytes32 signatureHash; uint64 drandRound; } struct DrawData { uint256 payout; uint256 gap; bool won; } enum RedemptionBlock { None, OwnerCapital, WindowClosed } address public immutable owner; uint64 public immutable roundOffset; bytes32 public immutable deploymentDomain; uint128 public bankrollAssets; uint128 public reservedAssets; uint64 public frozenAt; uint64 public oldestPendingRoundId; bool public emergencyInconsistency; mapping(uint64 roundId => Round round) private _rounds; mapping(uint64 roundId => mapping(uint64 positionIndex => Position position)) private _positions; mapping(uint64 roundId => mapping(uint64 positionIndex => bool claimed)) public refundClaimed; event Invested( address indexed investor, uint256 assets, uint256 shares, uint256 bankrollAssets, uint256 totalSupply ); event Redeemed( address indexed investor, address indexed recipient, uint256 shares, uint256 assets, uint256 bankrollAssets, uint256 totalSupply ); event BetPlaced( uint64 indexed roundId, uint64 indexed positionIndex, address indexed beneficiary, uint96 targetPayout, uint32 maxEdgeQ32, uint96 stake, uint128 aggregateStake, bytes32 positionCommitment ); event RoundResolved( uint64 indexed roundId, uint64 indexed selectedPositionIndex, address indexed resolver, bool won, uint256 payout, uint256 aggregateStake, uint256 settlementBankroll, uint64 closeBlock, uint64 randomnessBlock, bytes32 prevRandao, uint64 drandRound, bytes32 drandSignatureHash ); event BetClaimed( uint64 indexed roundId, uint64 indexed positionIndex, address indexed beneficiary, address claimer, uint256 stake, uint256 payout ); event RoundExpired( uint64 indexed roundId, address indexed expirer, uint64 closeBlock, uint256 forfeitedStake, uint256 settlementBankroll ); event RoundCancelled(uint64 indexed roundId, uint256 refundableStake); event StakeRefunded( uint64 indexed roundId, uint64 indexed positionIndex, address indexed beneficiary, address claimer, uint256 stake ); event Frozen(address indexed caller, uint256 frozenAt); event InconsistencyDetected( uint64 indexed roundId, address indexed challenger, uint64 recordedPositionIndex, bool recordedWon, uint64 provenPositionIndex, bool provenWon ); event Drained(address indexed owner, address indexed recipient, uint256 amount); error ZeroAddress(); error ZeroValue(); error ZeroShares(); error InsufficientOwnerSeed(uint256 minimum, uint256 actual); error InvalidConstructorParameters(); error InvalidBeneficiary(address beneficiary); error InvalidRecipient(address recipient); error BettingFrozen(); error InvestingFrozen(); error AlreadyFrozen(); error NotFrozen(); error DrainNotReady(uint256 availableAt); error Unauthorized(); error InvalidRound(); error InvalidPosition(); error InvalidTargetRound(uint64 earliestRoundId, uint64 actualRoundId); error TargetRoundTooFar(uint64 latestRoundId, uint64 actualRoundId); error RoundNotPending(); error EarlierRoundPending(uint64 roundId); error WinnerNotClaimable(); error ClaimNotReady(uint256 availableAt); error EmergencyInconsistencyActive(); error RefundNotClaimable(); error RoundStillOpen(uint256 closeBlock); error RandomnessBlockNotFinal(uint256 randomnessBlock); error ResolutionWindowExpired(uint256 lastBlock); error ResolutionWindowStillOpen(uint256 lastBlock); error CandidatePositionRequired(); error PositionDidNotWin(); error StakeTooLarge(uint256 maximum, uint256 actual); error PositionLimitReached(uint64 roundId); error AccountingCapacityExceeded(uint256 required, uint256 maximum); error InsufficientShares(uint256 required, uint256 available); error InsufficientCash(uint256 required, uint256 available); error InvalidBankrollState(uint256 bankroll, uint256 totalSupply); error BankrollLocked(uint64 roundId, uint256 lastBlock); error RedemptionWindowClosed(); error MinimumAssetsNotMet(uint256 minimum, uint256 actual); error MinimumSharesNotMet(uint256 minimum, uint256 actual); error ActiveBankrollRequired(); error OwnerCapitalLocked(); error NativeTransferFailed(); error InvalidShareRecipient(); error BlockHashUnavailable(uint256 randomnessBlock); error InvalidBlockHeader(); error DrandNotReady(uint256 readyAt); error InvalidDrandSignature(); error InvalidSchedule(); error BLSPrecompileSelfTestFailed(); error UnsupportedChain(uint256 actual); modifier onlyOwner() { if (msg.sender != owner) revert Unauthorized(); _; } /// @dev Emergency permits only owner recovery through drain; reads remain available. modifier whenConsistent() { _requireConsistent(); _; } function _requireConsistent() private view { if (emergencyInconsistency) revert EmergencyInconsistencyActive(); } constructor(address owner_) payable ERC20("LUCKOTTO", "LUCKOTTO") { if (block.chainid != MAINNET_CHAIN_ID && block.chainid != SEPOLIA_CHAIN_ID) { revert UnsupportedChain(block.chainid); } if (owner_ == address(0) || owner_ == address(this)) revert InvalidConstructorParameters(); if (msg.value == 0) revert ZeroValue(); if (msg.value < MINIMUM_OWNER_SEED) { revert InsufficientOwnerSeed(MINIMUM_OWNER_SEED, msg.value); } if (msg.value > type(uint128).max) { revert AccountingCapacityExceeded(msg.value, type(uint128).max); } if (!QuicknetBLS.selfTest()) revert BLSPrecompileSelfTestFailed(); uint256 offset = block.number / ROUND_BLOCKS; if (offset >= type(uint64).max / ROUND_BLOCKS) revert InvalidSchedule(); owner = owner_; roundOffset = uint64(offset); deploymentDomain = keccak256( abi.encode( PROTOCOL_DOMAIN, block.chainid, address(this), ROUND_BLOCKS, RANDAO_LOOKAHEAD_BLOCKS, QUICKNET_DELAY_SECONDS, WITHDRAWAL_WINDOW_BLOCKS, roundOffset, DRAND_CHAIN_HASH, QuicknetBLS.publicKeyHash() ) ); bankrollAssets = uint128(msg.value); _mint(owner_, msg.value); emit Invested(owner_, msg.value, msg.value, msg.value, msg.value); } /// @notice Invests all msg.value for shares rounded down, subject to a minimum share output. /// @dev Rounding remains in the bankroll; investment makes no external payment. function invest(uint256 minSharesOut) external payable whenConsistent nonReentrant returns (uint256 shares) { if (frozenAt != 0) revert InvestingFrozen(); _requireBankrollOpen(); uint256 assets = msg.value; if (assets == 0) revert ZeroValue(); uint256 bankroll = bankrollAssets; shares = _convertToShares(assets, bankroll, totalSupply()); if (shares == 0) revert ZeroShares(); if (shares < minSharesOut) revert MinimumSharesNotMet(minSharesOut, shares); uint256 nextBankroll = bankroll + assets; uint256 nextAccountedAssets = nextBankroll + uint256(reservedAssets); if (nextAccountedAssets > type(uint128).max) { revert AccountingCapacityExceeded(nextAccountedAssets, type(uint128).max); } bankrollAssets = uint128(nextBankroll); _mint(msg.sender, shares); emit Invested(msg.sender, assets, shares, bankrollAssets, totalSupply()); } /// @notice Quotes shares rounded down for investing the full asset amount. /// @dev Returns zero after full shutdown redemption; frozen pools cannot restart. function previewInvestment(uint256 assets) external view returns (uint256 shares) { return _convertToShares(assets, bankrollAssets, totalSupply()); } /// @notice Maximum shares currently eligible for redemption, excluding price/cash checks. function redeemableShares(address investor) public view returns (uint256 shares) { (shares,) = _redemptionEligibility(investor); } /// @notice Returns the bankroll assets received for burning shares at the current price. function previewRedeem(uint256 shares) public view returns (uint256 assets) { assets = _convertToAssets(shares, bankrollAssets, totalSupply()); } /// @notice Burns eligible shares for pro-rata bankroll assets at the current price. function redeem(uint256 shares, address payable recipient, uint256 minAssetsOut) external whenConsistent nonReentrant returns (uint256 assets) { if (shares == 0) revert ZeroShares(); if (recipient == address(0)) revert ZeroAddress(); if (recipient == address(this)) revert InvalidRecipient(recipient); uint256 investorShares = balanceOf(msg.sender); if (shares > investorShares) revert InsufficientShares(shares, investorShares); (, RedemptionBlock reason) = _redemptionEligibility(msg.sender); if (reason == RedemptionBlock.OwnerCapital) revert OwnerCapitalLocked(); if (reason == RedemptionBlock.WindowClosed) revert RedemptionWindowClosed(); uint256 bankroll = bankrollAssets; uint256 supply = totalSupply(); assets = _convertToAssets(shares, bankroll, supply); if (assets == 0) revert ZeroValue(); if (assets < minAssetsOut) revert MinimumAssetsNotMet(minAssetsOut, assets); bankrollAssets = uint128(bankroll - assets); _burn(msg.sender, shares); emit Redeemed(msg.sender, recipient, shares, assets, bankrollAssets, totalSupply()); _sendNative(recipient, assets); } /// @dev Active public exits require blocks 1 through 260 after a round boundary and /// no unresolved closed rounds. Empty rounds do not bypass the fixed window. /// Successful freeze unlocks all shares while preserving payout reserves. function _redemptionEligibility(address investor) private view returns (uint256 maximum, RedemptionBlock reason) { if (investor == owner && !shutdownComplete()) { return (0, RedemptionBlock.OwnerCapital); } if ( frozenAt == 0 && (block.number == 0 || (block.number - 1) % ROUND_BLOCKS >= WITHDRAWAL_WINDOW_BLOCKS || bankrollBlockedRound() != 0) ) { return (0, RedemptionBlock.WindowClosed); } maximum = balanceOf(investor); reason = RedemptionBlock.None; } /// @dev Shares sent directly to this contract cannot be recovered through the bankroll /// lifecycle. function _update(address from, address to, uint256 value) internal override { _requireConsistent(); if (to == address(this)) revert InvalidShareRecipient(); // Enforce the lock at the ERC-20 balance update so transferFrom cannot bypass it. if (from == owner && to != owner && value != 0 && !shutdownComplete()) { revert OwnerCapitalLocked(); } super._update(from, to, value); } function approve(address spender, uint256 value) public override whenConsistent returns (bool) { return super.approve(spender, value); } /// @notice Owner shares unlock after a successful freeze cancels the remaining open rounds. /// @dev Unclaimed refunds and winner payouts remain reserved and do not delay shutdown. function shutdownComplete() public view returns (bool) { return frozenAt != 0; } /// @notice Returns the closed pending round currently pausing investment and redemption. function bankrollBlockedRound() public view returns (uint64 roundId) { roundId = oldestPendingRoundId; if (roundId == 0 || block.number <= closeBlockForRound(roundId)) return 0; } /// @notice The earliest boundary round that can accept a position at `blockNumber`. function roundIdForBlock(uint256 blockNumber) public view returns (uint64 roundId) { uint256 absoluteRound = blockNumber / ROUND_BLOCKS; if (blockNumber % ROUND_BLOCKS != 0) ++absoluteRound; if (absoluteRound <= roundOffset) return 1; uint256 candidate = absoluteRound - roundOffset; if (candidate > type(uint64).max) revert InvalidSchedule(); roundId = uint64(candidate); } /// @notice The latest of the ten boundary rounds that can accept a position. function latestRoundIdForBlock(uint256 blockNumber) public view returns (uint64 roundId) { uint256 earliestRoundId = roundIdForBlock(blockNumber); uint256 candidate = earliestRoundId + MAX_FUTURE_ROUNDS - 1; roundId = candidate > type(uint64).max ? type(uint64).max : uint64(candidate); } function closeBlockForRound(uint64 roundId) public view returns (uint64 closeBlock) { uint256 absoluteRound = uint256(roundOffset) + roundId; if (roundId == 0 || absoluteRound > type(uint64).max / ROUND_BLOCKS) { revert InvalidSchedule(); } closeBlock = uint64(absoluteRound * ROUND_BLOCKS); } function randomnessBlockForRound(uint64 roundId) public view returns (uint64 randomnessBlock) { uint256 candidate = uint256(closeBlockForRound(roundId)) + RANDAO_LOOKAHEAD_BLOCKS; if (candidate > type(uint64).max) revert InvalidSchedule(); randomnessBlock = uint64(candidate); } function lastResolutionBlockForRound(uint64 roundId) public view returns (uint64 lastBlock) { uint256 candidate = uint256(randomnessBlockForRound(roundId)) + HISTORY_WINDOW_BLOCKS; if (candidate > type(uint64).max) revert InvalidSchedule(); lastBlock = uint64(candidate); } /// @notice Appends one immutable edge-capped position to one of the next ten boundary rounds. /// @dev `positionCommitment` is opaque caller data emitted without interpretation, /// validation, or incorporation into the draw. It is not retained in contract storage. function placeBet( uint64 roundId, uint96 targetPayout, uint32 maxEdgeQ32, address beneficiary, bytes32 positionCommitment ) external payable whenConsistent returns (uint64 positionIndex, uint256 rangeStart, uint256 aggregateStake) { _validateBetInput(beneficiary); _requireNoExpiredRound(); uint64 earliestRoundId = roundIdForBlock(block.number); if (roundId < earliestRoundId) revert InvalidTargetRound(earliestRoundId, roundId); uint64 latestRoundId = latestRoundIdForBlock(block.number); if (roundId > latestRoundId) revert TargetRoundTooFar(latestRoundId, roundId); // Validate that the target fits the supported block schedule. closeBlockForRound(roundId); Round storage round = _rounds[roundId]; bool firstPosition = round.aggregateStake == 0; uint256 nextAggregate = uint256(round.aggregateStake) + msg.value; // Pending stakes are included in reserves, so this also bounds nextAggregate. uint256 nextAccountedAssets = uint256(bankrollAssets) + uint256(reservedAssets) + msg.value; if (nextAccountedAssets > type(uint128).max) { revert AccountingCapacityExceeded(nextAccountedAssets, type(uint128).max); } if (round.positionCount == type(uint64).max) revert PositionLimitReached(roundId); positionIndex = round.positionCount + 1; rangeStart = round.aggregateStake; _positions[roundId][positionIndex] = Position({ beneficiary: beneficiary, stake: uint96(msg.value), targetPayout: targetPayout, maxEdgeQ32: maxEdgeQ32, rangeStart: uint128(rangeStart) }); round.positionCount = positionIndex; round.aggregateStake = uint128(nextAggregate); reservedAssets = uint128(uint256(reservedAssets) + msg.value); if (firstPosition) _trackPendingRound(roundId); emit BetPlaced( roundId, positionIndex, beneficiary, targetPayout, maxEdgeQ32, uint96(msg.value), uint128(nextAggregate), positionCommitment ); return (positionIndex, rangeStart, nextAggregate); } function _validateBetInput(address beneficiary) private view { if (frozenAt != 0) revert BettingFrozen(); // Expired stakes belong to existing shareholders; bets require a funded bankroll. if (bankrollAssets == 0) revert ActiveBankrollRequired(); if (msg.value == 0) revert ZeroValue(); if (beneficiary == address(0)) revert ZeroAddress(); if (beneficiary == address(this)) revert InvalidBeneficiary(beneficiary); if (msg.value > type(uint96).max) { revert StakeTooLarge(type(uint96).max, msg.value); } } /// @notice Describes any round identifier without reverting for empty or invalid rounds. /// @dev `validRoundId` reports whether the identifier maps to a supported close block, while /// `hasPositions` reports whether any round storage has been initialized by a bet. function getRoundInfo(uint64 roundId) external view returns (bool validRoundId, bool hasPositions, uint64 closeBlock, Round memory round) { round = _rounds[roundId]; hasPositions = round.aggregateStake != 0; uint256 absoluteRound = uint256(roundOffset) + roundId; validRoundId = roundId != 0 && absoluteRound <= type(uint64).max / ROUND_BLOCKS; if (validRoundId) closeBlock = uint64(absoluteRound * ROUND_BLOCKS); } function getPosition(uint64 roundId, uint64 positionIndex) external view returns (Position memory position) { position = _requirePosition(roundId, positionIndex); } /// @notice Quotes a position with the current or settled bankroll, or its cancelled stake /// refund. function quotePosition(uint64 roundId, uint64 positionIndex) external view returns (uint256 payout, uint256 gap, uint256 edge, uint256 winProbability) { Round storage round = _requireRound(roundId); Position storage position = _requirePosition(roundId, positionIndex); if (round.state == RoundState.Cancelled) { return (position.stake, 0, 0, PROBABILITY_SCALE); } return _quotePosition( round.aggregateStake, position.stake, position.targetPayout, position.maxEdgeQ32, _quoteBankroll(round) ); } /// @notice Resolves a pending round on time, or checks a settled result for inconsistency. /// @dev A second proof uses the original bankroll and authenticated header, without settling /// again. A different candidate or win/loss result permanently restricts public mutations. function settle( uint64 roundId, uint64 candidatePositionIndex, bytes calldata rlpHeader, bytes calldata drandSignature ) external whenConsistent returns (bool won) { if (candidatePositionIndex == 0) { revert CandidatePositionRequired(); } Round storage round = _requireRound(roundId); bool pending = round.state == RoundState.Pending; if (pending) { _requireOldestPendingRound(roundId); } else if ( round.state != RoundState.WinnerClaimable && round.state != RoundState.WinnerPaid && round.state != RoundState.NoWinner ) { revert RoundNotPending(); } Position storage position = _requirePosition(roundId, candidatePositionIndex); uint128 settlementBankroll = pending ? bankrollAssets : round.settlementBankroll; ProofInputs memory proof = _validateProof(roundId, rlpHeader, drandSignature, round.randomnessBlockHash); bytes32 seed = _drawSeed( roundId, round, settlementBankroll, proof.prevRandao, proof.drandRound, proof.signatureHash ); DrawData memory draw = _evaluateDraw(candidatePositionIndex, round, position, settlementBankroll, seed); won = draw.won; if (!pending) { bool recordedWon = round.state != RoundState.NoWinner; if (candidatePositionIndex != round.selectedPositionIndex || won != recordedWon) { emergencyInconsistency = true; emit InconsistencyDetected( roundId, msg.sender, round.selectedPositionIndex, recordedWon, candidatePositionIndex, won ); } return won; } round.randomnessBlockHash = keccak256(rlpHeader); round.settledAt = uint64(block.timestamp); _finalizeRound(roundId, candidatePositionIndex, round, settlementBankroll, draw, proof); } /// @notice Pays the winning position's immutable beneficiary. Anyone may submit the claim. function claimWinner(uint64 roundId) external whenConsistent nonReentrant { Round storage round = _requireRound(roundId); Position storage position = _claimablePosition(roundId, round); uint256 availableAt = uint256(round.settledAt) + CLAIM_DELAY; if (block.timestamp < availableAt) revert ClaimNotReady(availableAt); _payWinner(roundId, round, position); } /// @notice Forfeits an unresolved round's stakes to the bankroll after its proof deadline. function expireRound(uint64 roundId) external whenConsistent { Round storage round = _requireRound(roundId); if (round.state != RoundState.Pending) revert RoundNotPending(); uint256 lastBlock = lastResolutionBlockForRound(roundId); if (block.number <= lastBlock) revert ResolutionWindowStillOpen(lastBlock); _requireOldestPendingRound(roundId); _expireRound(roundId, round); } /// @notice Pays a cancelled position's full stake to its immutable beneficiary, once. /// @dev Anyone may submit the claim. A rejected payment rolls back the claim and its reserve. function claimRefund(uint64 roundId, uint64 positionIndex) external whenConsistent nonReentrant { Round storage round = _requireRound(roundId); if (round.state != RoundState.Cancelled || refundClaimed[roundId][positionIndex]) { revert RefundNotClaimable(); } Position storage position = _requirePosition(roundId, positionIndex); uint256 stake = position.stake; uint256 cash = address(this).balance; if (cash < stake) revert InsufficientCash(stake, cash); refundClaimed[roundId][positionIndex] = true; reservedAssets = uint128(uint256(reservedAssets) - stake); emit StakeRefunded(roundId, positionIndex, position.beneficiary, msg.sender, stake); _sendNative(payable(position.beneficiary), stake); } /// @notice Permanently stops new activity and cancels all still-open rounds for refunds. /// @dev Every closed round must settle or expire first. The inclusive close block remains /// open. Cancellation touches at most ten rounds and never calls a beneficiary. function freeze() external onlyOwner whenConsistent { if (frozenAt != 0) revert AlreadyFrozen(); _requireBankrollOpen(); frozenAt = uint64(block.timestamp); uint64 first = oldestPendingRoundId; if (first != 0) { uint256 end = uint256(first) + MAX_FUTURE_ROUNDS; for (uint256 roundId = first; roundId < end; ++roundId) { Round storage round = _rounds[uint64(roundId)]; if (round.aggregateStake != 0 && round.state == RoundState.Pending) { round.state = RoundState.Cancelled; emit RoundCancelled(uint64(roundId), round.aggregateStake); } } oldestPendingRoundId = 0; } emit Frozen(msg.sender, block.timestamp); } /// @notice Transfers all cash immediately during emergency, or one year after freezing, /// without changing accounting. /// @dev Intentional recovery failsafe for otherwise unrecoverable funds. This may remove /// cash backing unpaid claims, refunds, and shares; it is not limited to surplus. function drain(address payable recipient) external onlyOwner nonReentrant { bool emergency = emergencyInconsistency; uint256 frozen = frozenAt; if (!emergency && frozen == 0) revert NotFrozen(); if (recipient == address(0)) revert ZeroAddress(); if (recipient == address(this)) revert InvalidRecipient(recipient); uint256 availableAt = frozen + DRAIN_DELAY; if (!emergency && block.timestamp < availableAt) revert DrainNotReady(availableAt); uint256 amount = address(this).balance; emit Drained(msg.sender, recipient, amount); if (amount != 0) { _sendNative(recipient, amount); } } function _convertToShares(uint256 assets, uint256 bankroll, uint256 supply) internal pure returns (uint256 shares) { if (assets == 0 || supply == 0) return 0; if (bankroll == 0) revert InvalidBankrollState(bankroll, supply); return Math.mulDiv(assets, supply, bankroll); } function _convertToAssets(uint256 shares, uint256 bankroll, uint256 supply) internal pure returns (uint256 assets) { if (shares == 0 || supply == 0) return 0; if (bankroll == 0) revert InvalidBankrollState(bankroll, supply); return Math.mulDiv(shares, bankroll, supply); } function _requireBankrollOpen() private view { uint64 blockedRoundId = bankrollBlockedRound(); if (blockedRoundId != 0) { revert BankrollLocked(blockedRoundId, lastResolutionBlockForRound(blockedRoundId)); } } function _requireNoExpiredRound() private view { uint64 roundId = oldestPendingRoundId; if (roundId != 0 && block.number > lastResolutionBlockForRound(roundId)) { revert EarlierRoundPending(roundId); } } function _requireOldestPendingRound(uint64 roundId) private view { uint64 oldestRoundId = oldestPendingRoundId; if (roundId != oldestRoundId) revert EarlierRoundPending(oldestRoundId); } function _trackPendingRound(uint64 roundId) private { uint64 oldestRoundId = oldestPendingRoundId; if (oldestRoundId == 0 || roundId < oldestRoundId) oldestPendingRoundId = roundId; } function _advanceOldestPendingRound(uint64 finalizedRoundId) private { oldestPendingRoundId = 0; // Before this round's proof window closes, placement can reach at most ten rounds beyond // it. Once overdue, _requireNoExpiredRound prevents the pending set from growing further. uint256 lastCandidate = uint256(finalizedRoundId) + MAX_FUTURE_ROUNDS; if (lastCandidate > type(uint64).max) lastCandidate = type(uint64).max; for ( uint256 candidate = uint256(finalizedRoundId) + 1; candidate <= lastCandidate; ++candidate ) { Round storage round = _rounds[uint64(candidate)]; if (round.aggregateStake != 0 && round.state == RoundState.Pending) { oldestPendingRoundId = uint64(candidate); break; } } } function _quoteBankroll(Round storage round) private view returns (uint256) { return round.state == RoundState.Pending ? bankrollAssets : round.settlementBankroll; } function _quotePayout( uint256 aggregateStake, uint256 targetPayout, uint256 maxEdgeQ32, uint256 bankroll ) private pure returns (uint256 payout, uint256 gap) { payout = aggregateStake > targetPayout ? aggregateStake : targetPayout; uint256 requestedGap = payout - aggregateStake; uint256 maximumGap = Math.mulDiv(bankroll, maxEdgeQ32, EDGE_SCALE); gap = requestedGap > maximumGap ? maximumGap : requestedGap; payout = aggregateStake + gap; } function _quotePosition( uint256 aggregateStake, uint256 stake, uint256 targetPayout, uint256 maxEdgeQ32, uint256 bankroll ) internal pure returns (uint256 payout, uint256 gap, uint256 edge, uint256 winProbability) { (payout, gap) = _quotePayout(aggregateStake, targetPayout, maxEdgeQ32, bankroll); // Existing rounds always have a positive current or snapshotted bankroll. edge = Math.mulDiv(gap, PROBABILITY_SCALE, bankroll); uint256 probabilityNumerator = stake * (bankroll - gap); uint256 probabilityDenominator = payout * bankroll; winProbability = Math.mulDiv(probabilityNumerator, PROBABILITY_SCALE, probabilityDenominator); } function _validateProof( uint64 roundId, bytes calldata rlpHeader, bytes calldata drandSignature, bytes32 storedHeaderHash ) private view returns (ProofInputs memory proof) { uint256 closeBlock = closeBlockForRound(roundId); if (block.number <= closeBlock) revert RoundStillOpen(closeBlock); uint256 randomnessBlock = randomnessBlockForRound(roundId); if (block.number <= randomnessBlock) revert RandomnessBlockNotFinal(randomnessBlock); // Only the first settlement depends on expiring history. Later checks authenticate // the same header against its stored hash and never reopen settlement or expiry. if (storedHeaderHash == bytes32(0)) { uint256 lastBlock = lastResolutionBlockForRound(roundId); if (block.number > lastBlock) revert ResolutionWindowExpired(lastBlock); } (uint64 headerBlock, uint64 headerTimestamp, bytes32 prevRandao) = EthereumBlockHeader.parse(rlpHeader); if (headerBlock != randomnessBlock) revert InvalidBlockHeader(); if (storedHeaderHash == bytes32(0)) { storedHeaderHash = _historicalBlockHash(headerBlock); } if (keccak256(rlpHeader) != storedHeaderHash) revert InvalidBlockHeader(); uint64 drandRound = _roundAtOrAfter(uint256(headerTimestamp) + QUICKNET_DELAY_SECONDS); uint256 readyAt = _roundTime(drandRound); if (block.timestamp < readyAt) revert DrandNotReady(readyAt); (bool validSignature, bytes32 signatureHash) = _verifyDrandSignature(drandSignature, drandRound); if (!validSignature) revert InvalidDrandSignature(); proof.prevRandao = prevRandao; proof.drandRound = drandRound; proof.signatureHash = signatureHash; } function _evaluateDraw( uint64 candidatePositionIndex, Round storage round, Position storage position, uint128 settlementBankroll, bytes32 seed ) private view returns (DrawData memory draw) { uint256 candidatePoint = _candidatePoint(seed, round.aggregateStake); if (!_contains(position, candidatePoint)) revert PositionDidNotWin(); (draw.payout, draw.gap) = _quotePayout( round.aggregateStake, position.targetPayout, position.maxEdgeQ32, settlementBankroll ); (uint256 coveragePoint, uint256 riskPoint) = _gatePoints(seed, candidatePositionIndex, draw.payout, settlementBankroll); draw.won = coveragePoint < round.aggregateStake && riskPoint < settlementBankroll - draw.gap; } function _finalizeRound( uint64 roundId, uint64 candidatePositionIndex, Round storage round, uint128 settlementBankroll, DrawData memory draw, ProofInputs memory proof ) private { round.selectedPositionIndex = candidatePositionIndex; round.settlementBankroll = settlementBankroll; uint256 aggregateStake = round.aggregateStake; if (draw.won) { round.state = RoundState.WinnerClaimable; bankrollAssets = uint128(uint256(settlementBankroll) - draw.gap); reservedAssets = uint128(uint256(reservedAssets) + draw.gap); } else { round.state = RoundState.NoWinner; _creditRoundStake(aggregateStake, settlementBankroll); } _advanceOldestPendingRound(roundId); emit RoundResolved( roundId, candidatePositionIndex, msg.sender, draw.won, draw.payout, aggregateStake, settlementBankroll, closeBlockForRound(roundId), randomnessBlockForRound(roundId), proof.prevRandao, proof.drandRound, proof.signatureHash ); } function _expireRound(uint64 roundId, Round storage round) private { uint128 settlementBankroll = bankrollAssets; round.state = RoundState.Expired; round.settlementBankroll = settlementBankroll; // The round was not settled before the deadline: its stakes become bankroll profit. _creditRoundStake(round.aggregateStake, settlementBankroll); _advanceOldestPendingRound(roundId); emit RoundExpired( roundId, msg.sender, closeBlockForRound(roundId), round.aggregateStake, settlementBankroll ); } function _creditRoundStake(uint256 aggregateStake, uint128 settlementBankroll) private { bankrollAssets = uint128(uint256(settlementBankroll) + aggregateStake); reservedAssets = uint128(uint256(reservedAssets) - aggregateStake); } function _roundAtOrAfter(uint256 timestamp) internal pure returns (uint64 round) { if (timestamp <= DRAND_GENESIS_TIME) return 1; uint256 candidate = (timestamp - DRAND_GENESIS_TIME + DRAND_PERIOD_SECONDS - 1) / DRAND_PERIOD_SECONDS + 1; if (candidate > type(uint64).max) revert InvalidSchedule(); round = uint64(candidate); } function _roundTime(uint64 round) internal pure returns (uint256) { if (round == 0) revert InvalidSchedule(); return DRAND_GENESIS_TIME + (uint256(round) - 1) * DRAND_PERIOD_SECONDS; } function _drawSeed( uint64 roundId, Round storage round, uint128 settlementBankroll, bytes32 prevRandao, uint64 drandRound, bytes32 drandSignatureHash ) internal view returns (bytes32) { return keccak256( abi.encode( DRAW_DOMAIN, deploymentDomain, roundId, closeBlockForRound(roundId), randomnessBlockForRound(roundId), round.aggregateStake, settlementBankroll, prevRandao, drandRound, drandSignatureHash ) ); } function _candidatePoint(bytes32 seed, uint256 aggregateStake) internal pure returns (uint256) { return uint256(keccak256(abi.encode(CANDIDATE_DRAW_DOMAIN, seed))) % aggregateStake; } function _gatePoints( bytes32 seed, uint64 positionIndex, uint256 payout, uint256 settlementBankroll ) internal pure returns (uint256 coveragePoint, uint256 riskPoint) { coveragePoint = uint256(keccak256(abi.encode(COVERAGE_DRAW_DOMAIN, seed, positionIndex))) % payout; riskPoint = uint256(keccak256(abi.encode(RISK_DRAW_DOMAIN, seed, positionIndex))) % settlementBankroll; } function _historicalBlockHash(uint64 randomnessBlock) internal view virtual returns (bytes32 trustedHash) { (bool ok, bytes memory result) = HISTORY_STORAGE_ADDRESS.staticcall(abi.encode(uint256(randomnessBlock))); if (!ok || result.length != 32) revert BlockHashUnavailable(randomnessBlock); trustedHash = abi.decode(result, (bytes32)); if (trustedHash == bytes32(0)) revert BlockHashUnavailable(randomnessBlock); } function _requireRound(uint64 roundId) internal view returns (Round storage round) { round = _rounds[roundId]; if (round.aggregateStake == 0) revert InvalidRound(); } function _requirePosition(uint64 roundId, uint64 positionIndex) internal view returns (Position storage position) { position = _positions[roundId][positionIndex]; if (position.beneficiary == address(0)) revert InvalidPosition(); } function _contains(Position storage position, uint256 point) private view returns (bool) { uint256 rangeEnd = uint256(position.rangeStart) + position.stake; return point >= position.rangeStart && point < rangeEnd; } function _claimablePosition(uint64 roundId, Round storage round) private view returns (Position storage position) { if (round.state != RoundState.WinnerClaimable) revert WinnerNotClaimable(); position = _requirePosition(roundId, round.selectedPositionIndex); } function _payWinner(uint64 roundId, Round storage round, Position storage position) private { (uint256 payout,) = _quotePayout( round.aggregateStake, position.targetPayout, position.maxEdgeQ32, round.settlementBankroll ); uint256 cash = address(this).balance; if (cash < payout) revert InsufficientCash(payout, cash); round.state = RoundState.WinnerPaid; reservedAssets = uint128(uint256(reservedAssets) - payout); emit BetClaimed( roundId, round.selectedPositionIndex, position.beneficiary, msg.sender, position.stake, payout ); _sendNative(payable(position.beneficiary), payout); } /// @dev ETH recipients control return data; never allocate or copy it when only success /// matters. function _sendNative(address payable recipient, uint256 amount) private { bool sent; assembly ("memory-safe") { sent := call(gas(), recipient, amount, 0, 0, 0, 0) } if (!sent) revert NativeTransferFailed(); } function _verifyDrandSignature(bytes memory signature, uint64 targetRound) internal view virtual returns (bool valid, bytes32 canonicalHash) { return QuicknetBLS.verifyAndHash(signature, targetRound); } }