// SPDX-License-Identifier: MIT pragma solidity 0.8.36; import { QuicknetBLS } from "./libraries/QuicknetBLS.sol"; import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import { Blockhash } from "@openzeppelin/contracts/utils/Blockhash.sol"; import { Memory } from "@openzeppelin/contracts/utils/Memory.sol"; import { RLP } from "@openzeppelin/contracts/utils/RLP.sol"; import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; /// @title Luckotto Weekly ETH Lottery /// @notice Immutable native-ETH lottery settled by a mechanically selected quicknet signature. contract Luckotto is ERC20, ReentrancyGuard { using Math for uint256; using Memory for Memory.Slice; using RLP for Memory.Slice; uint256 public constant ROUND_SECONDS = 604_800; uint256 public constant LIQUIDITY_WINDOW_SECONDS = 3600; uint256 public constant DRAND_DELAY_SECONDS = 3600; uint256 public constant DRAND_RESOLUTION_WINDOW_SECONDS = 86_400; uint256 public constant RESOLVER_BOUNTY_RAMP_SECONDS = 75_600; /// @dev Ethereum consensus slots are 12 seconds and contain at most one execution block. uint256 public constant ETHEREUM_SLOT_SECONDS = 12; uint256 public constant ETHEREUM_SLOTS_PER_EPOCH = 32; uint256 public constant ENTROPY_LOOKAHEAD_EPOCHS = 4; /// @dev Four epochs plus four additional slots, following EIP-4399 guidance. uint256 public constant ENTROPY_LOOKAHEAD_SLOTS = ENTROPY_LOOKAHEAD_EPOCHS * ETHEREUM_SLOTS_PER_EPOCH + 4; /// @dev The extra block makes the target header expose its predecessor's mix after the full /// lookahead. Missed slots only move this target later. uint256 public constant ENTROPY_BLOCK_OFFSET = (LIQUIDITY_WINDOW_SECONDS + ROUND_SECONDS) / ETHEREUM_SLOT_SECONDS + ENTROPY_LOOKAHEAD_SLOTS + 1; /// @dev Earliest permitted target-header timestamp relative to the sales close. uint256 public constant ENTROPY_MIN_DELAY_SECONDS = (ENTROPY_LOOKAHEAD_SLOTS + 1) * ETHEREUM_SLOT_SECONDS; /// @dev EIP-2935 serves [block.number - 8191, block.number - 1]. uint256 public constant EIP2935_HISTORY_WINDOW = 8191; uint256 public constant MAX_ENTROPY_BLOCK_HEADER_BYTES = 2048; uint256 private constant MIN_EXECUTION_HEADER_FIELDS = 15; uint256 private constant HEADER_DIFFICULTY_INDEX = 7; uint256 private constant HEADER_BLOCK_NUMBER_INDEX = 8; uint256 private constant HEADER_TIMESTAMP_INDEX = 11; uint256 private constant HEADER_PREVRANDAO_INDEX = 13; uint256 private constant RLP_BYTES32_ENCODED_LENGTH = 33; /// @dev Covers roughly 300,000 gas at 0.33 gwei before the auction raises the quote. uint256 public constant BOOTSTRAP_RESOLVER_BOUNTY = 0.0001 ether; uint256 public constant RESOLVER_BOUNTY_CARRYOVER_NUMERATOR = 9; uint256 public constant RESOLVER_BOUNTY_CARRYOVER_DENOMINATOR = 10; uint256 private constant PERFORMANCE_FEE_BPS = 1000; uint256 private constant BPS_SCALE = 10_000; uint256 private constant SWEEP_DELAY = 730 days; uint256 public constant PAYOUT_SCALE = 1 << 128; bytes32 public constant DRAND_CHAIN_HASH = 0x52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971; uint256 public constant DRAND_PERIOD_SECONDS = 3; uint256 public constant DRAND_GENESIS_TIME = 1_692_803_367; string public constant DRAND_SCHEME_ID = "bls-unchained-g1-rfc9380"; string public constant DRAND_BLS_DST = "BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_"; bytes32 public constant PROTOCOL_DOMAIN = keccak256("LUCKOTTO_PROTOCOL_V1"); bytes32 public constant DRAW_SEED_DOMAIN = keccak256("LUCKOTTO_DRAW_SEED_V1"); bytes32 public constant CANDIDATE_DOMAIN = keccak256("LUCKOTTO_CANDIDATE_V1"); bytes32 public constant ACCEPTANCE_DOMAIN = keccak256("LUCKOTTO_ACCEPTANCE_V1"); uint256 public immutable MAX_RESOLVER_BOUNTY; address private immutable _feeRecipient; bytes32 public immutable deploymentDomain; enum Status { Liquidity, Selling, Advanceable, WaitingForEntropyBlock, WaitingForDrand, ReadyForResolution, WaitingForAbort, Abortable, Aborted, Closed } enum Outcome { Pending, Empty, NoWinner, Winner, Aborted } enum DeploymentStatus { Active, Aborted, Closed } struct RoundResult { Outcome outcome; uint256 winnerTicketId; uint128 winningPayout; } struct Ticket { uint256 roundId; address buyer; uint128 stake; uint128 targetPayout; uint128 cumulativeEnd; bool paid; } struct EntropyBlock { uint256 number; uint256 timestamp; bytes32 hash; bytes32 prevRandao; } DeploymentStatus public deploymentStatus; uint256 public roundId; uint256 public nextTicketId; uint256 public outstandingClaims; uint256 public salesOpenTime; uint256 public salesCloseTime; uint128 public P; uint128 public Q; uint256 public ticketCount; uint256 public entropyBlockNumber; /// @notice Highest post-fee net asset value per share, scaled by `PAYOUT_SCALE`. uint256 public highWaterMark; uint64 private _abortedAt; mapping(address resolver => uint256 amount) public resolverBountyCredit; mapping(uint256 id => RoundResult result) public roundResult; /// @notice Starting auction quote assigned when each round opens. mapping(uint256 id => uint256 amount) public roundResolverBountyStart; /// @notice Final auction quote for each resolved round before applying its bankroll cap. mapping(uint256 id => uint256 amount) public roundResolverBountyQuote; /// @notice Bounty actually reserved for the resolver after applying the round bankroll cap. mapping(uint256 id => uint256 amount) public roundResolverBounty; mapping(uint256 id => Ticket ticket) public tickets; event RoundOpened( uint256 indexed roundId, uint256 Q, uint256 salesOpenTime, uint256 salesCloseTime, uint256 entropyBlockNumber, uint256 resolverBountyStart ); event RoundSkipped(uint256 indexed roundId); event TicketBought( uint256 indexed roundId, uint256 indexed ticketId, address indexed buyer, uint256 stake, uint256 targetPayout, uint256 cumulativeEnd ); event RoundResolved( uint256 indexed roundId, uint256 entropyBlockNumber, uint256 entropyBlockTimestamp, bytes32 entropyBlockHash, bytes32 entropyPrevRandao, uint64 targetDrandRound, bytes32 drandSignatureHash, bytes32 seed, uint256 P, uint256 Q, uint256 ticketCount, Outcome outcome, uint256 candidateTicketId, uint256 winnerTicketId, uint256 winningPayout, uint256 resolverBountyStart, uint256 resolverBountyQuote, uint256 resolverBounty, address indexed resolver ); event RoundAborted(uint256 indexed roundId, uint256 refundableAmount); event DeploymentClosed(uint256 indexed roundId); event TicketClaimed( uint256 indexed roundId, uint256 indexed ticketId, Outcome outcome, address indexed recipient, uint256 amount ); event ResolverBountyClaimed( address indexed resolver, address indexed recipient, uint256 amount ); event SharesMinted( uint256 indexed roundId, address indexed payer, address indexed recipient, uint256 assets, uint256 shares, uint256 refund ); event SharesRedeemed( uint256 indexed roundId, address indexed owner, address indexed recipient, uint256 assets, uint256 shares, bool closesDeployment ); event TerminalSharesRedeemed( address indexed shareholder, address indexed recipient, uint256 shares, uint256 assets ); event PerformanceFeeMinted( uint256 indexed roundId, address indexed recipient, uint256 profitAssets, uint256 feeAssets, uint256 feeShares, uint256 highWaterMark ); event EqualizationPremiumPaid( uint256 indexed roundId, address indexed payer, address indexed shareRecipient, uint256 premiumAssets ); event EmergencyAbort(uint256 indexed roundId, address indexed caller); event Swept(address indexed recipient, uint256 amount); error ZeroAddress(); error InvalidConstructorParameters(); error DeploymentInactive(); error WrongRound(uint256 expected, uint256 actual); error SalesClosed(); error SalesNotOpen(); error SalesStillOpen(); error LiquidityWindowClosed(); error ValueOutOfRange(); error TargetBelowStake(); error Unauthorized(); error NoTickets(); error RoundNotEmpty(); error DrandNotReady(); error ResolutionWindowExpired(); error ResolutionWindowActive(); error InvalidDrandSignature(); error InvalidTicket(); error TicketNotClaimable(); error TicketAlreadyPaid(); error NoResolverBounty(); error ZeroValue(); error CannotTransferSharesToLottery(); error NoTerminalEquity(); error ZeroOutput(); error NativeTransferFailed(); error DirectPaymentRejected(); error InvalidSchedule(); error EntropyBlockNotAvailable(); error EntropyBlockHistoryExpired(); error InvalidEntropyBlockHeader(); error EntropyBlockTooEarly(uint256 minimumTimestamp, uint256 actualTimestamp); error BLSPrecompileSelfTestFailed(); error InsufficientAssets(uint256 required, uint256 provided); error MinimumAssetsNotMet(uint256 minimum, uint256 actual); error AbortWindowClosed(); error SweepNotReady(); constructor( address initialInvestor, address feeRecipient, string memory shareName, string memory shareSymbol, uint256 maxResolverBounty ) payable ERC20(shareName, shareSymbol) { if ( initialInvestor == address(0) || feeRecipient == address(0) || feeRecipient == address(this) || bytes(shareName).length == 0 || bytes(shareSymbol).length == 0 || maxResolverBounty < BOOTSTRAP_RESOLVER_BOUNTY || msg.value == 0 || msg.value > type(uint128).max || initialInvestor == address(this) ) revert InvalidConstructorParameters(); if (!QuicknetBLS.selfTest()) revert BLSPrecompileSelfTestFailed(); MAX_RESOLVER_BOUNTY = maxResolverBounty; _feeRecipient = feeRecipient; deploymentDomain = keccak256( abi.encode(PROTOCOL_DOMAIN, block.chainid, address(this), DRAND_CHAIN_HASH) ); nextTicketId = 1; highWaterMark = PAYOUT_SCALE; _mint(initialInvestor, msg.value); _openNextRound(msg.value, BOOTSTRAP_RESOLVER_BOUNTY); } receive() external payable { revert DirectPaymentRejected(); } fallback() external payable { revert DirectPaymentRejected(); } function buyTicket(uint256 expectedRoundId, uint128 targetPayout) external payable { _requireActiveRound(expectedRoundId); if (block.timestamp < salesOpenTime) revert SalesNotOpen(); if (block.timestamp >= salesCloseTime) revert SalesClosed(); if (ticketCount == 0) { uint256 openingEquity = _unreservedBalance() - msg.value; if (openingEquity > type(uint128).max) revert ValueOutOfRange(); Q = uint128(openingEquity); } uint256 stake = msg.value; if (stake == 0) revert ZeroValue(); if (stake > type(uint128).max) revert ValueOutOfRange(); if (stake > targetPayout) revert TargetBelowStake(); uint256 newP = uint256(P) + stake; if (newP > type(uint128).max) revert ValueOutOfRange(); _requireSuccessorCapacity(newP); uint256 ticketId = nextTicketId; tickets[ticketId] = Ticket({ roundId: roundId, buyer: msg.sender, stake: uint128(stake), targetPayout: targetPayout, cumulativeEnd: uint128(newP), paid: false }); P = uint128(newP); ++ticketCount; ++nextTicketId; emit TicketBought(roundId, ticketId, msg.sender, stake, targetPayout, newP); } /// @notice Mints an exact number of shares during the inter-round liquidity window. /// @dev Required assets round up so a mint cannot dilute existing shareholders. Any excess /// msg.value is returned to the caller. function mintShares(uint256 expectedRoundId, uint256 shares, address recipient) external payable nonReentrant returns (uint256 assets) { _requireLiquidityWindow(expectedRoundId); if (recipient == address(0)) revert ZeroAddress(); if (recipient == address(this)) revert CannotTransferSharesToLottery(); if (shares == 0) revert ZeroValue(); uint256 equity = _unreservedBalance() - msg.value; if (equity == 0) revert NoTerminalEquity(); uint256 premiumAssets; (assets, premiumAssets) = _mintQuote(shares, equity, totalSupply()); if (assets > msg.value) revert InsufficientAssets(assets, msg.value); uint256 nextQ = equity + assets; if (nextQ > type(uint128).max) revert ValueOutOfRange(); Q = uint128(nextQ); _mint(recipient, shares); if (premiumAssets != 0) { emit EqualizationPremiumPaid(roundId, msg.sender, recipient, premiumAssets); } uint256 refund = msg.value - assets; if (refund != 0) _sendNative(payable(msg.sender), refund); emit SharesMinted(roundId, msg.sender, recipient, assets, shares, refund); } /// @notice Burns shares for assets during the inter-round liquidity window. /// @dev Assets round down in favor of the remaining shareholders. Redeeming the complete /// supply returns all equity and permanently closes the deployment. function redeemShares( uint256 expectedRoundId, uint256 shares, address payable recipient, uint256 minAssets ) external nonReentrant returns (uint256 assets) { _requireLiquidityWindow(expectedRoundId); if (recipient == address(0)) revert ZeroAddress(); if (shares == 0) revert ZeroValue(); uint256 equity = _unreservedBalance(); uint256 supply = totalSupply(); assets = shares == supply ? equity : shares.mulDiv(equity, supply); if (assets == 0) revert ZeroOutput(); if (assets < minAssets) revert MinimumAssetsNotMet(minAssets, assets); _burn(msg.sender, shares); uint256 nextQ = equity - assets; bool closesDeployment = totalSupply() == 0; if (closesDeployment) { roundResult[roundId] = RoundResult(Outcome.Empty, 0, 0); deploymentStatus = DeploymentStatus.Closed; Q = 0; emit DeploymentClosed(roundId); } else { if (nextQ > type(uint128).max) revert ValueOutOfRange(); Q = uint128(nextQ); } _sendNative(recipient, assets); emit SharesRedeemed(roundId, msg.sender, recipient, assets, shares, closesDeployment); } /// @notice Resolves the round using the canonical raw RLP header of its fixed entropy block. /// @dev `entropyBlockHeaderRlp` must hash to the block at `entropyBlockNumber`. function resolveRound( uint256 expectedRoundId, bytes calldata entropyBlockHeaderRlp, bytes calldata drandSignature ) external nonReentrant { _requireActiveRound(expectedRoundId); if (ticketCount == 0) revert NoTickets(); EntropyBlock memory entropy = _authenticatedEntropyBlock(entropyBlockHeaderRlp); (uint64 targetRound, uint256 targetTime, uint256 deadline) = _deriveSchedule(entropy.timestamp); if (block.timestamp < targetTime) revert DrandNotReady(); if (block.timestamp > deadline) revert ResolutionWindowExpired(); if (drandSignature.length != 96) revert InvalidDrandSignature(); if (!_verifyDrandSignature(drandSignature, targetRound)) { revert InvalidDrandSignature(); } bytes32 signatureHash = sha256(drandSignature); bytes32 seed = keccak256( abi.encode( DRAW_SEED_DOMAIN, deploymentDomain, roundId, entropy.number, entropy.prevRandao, targetRound, signatureHash ) ); _settleResolvedRound(entropy, targetRound, signatureHash, seed); } function advanceEmptyRound(uint256 expectedRoundId) external nonReentrant { _requireActiveRound(expectedRoundId); if (ticketCount != 0) revert RoundNotEmpty(); if (block.timestamp < salesCloseTime) revert SalesStillOpen(); uint256 skippedRoundId = roundId; roundResult[skippedRoundId] = RoundResult(Outcome.Empty, 0, 0); emit RoundSkipped(skippedRoundId); _openNextRound(_unreservedBalance(), roundResolverBountyStart[skippedRoundId]); } /// @notice Permanently stops the deployment and refunds current tickets before entropy can be /// known. Only the immutable performance-fee recipient may call this function. function abort() external { if (msg.sender != _feeRecipient) revert Unauthorized(); if (deploymentStatus != DeploymentStatus.Active) revert DeploymentInactive(); if (block.timestamp >= salesCloseTime || block.number >= entropyBlockNumber) { revert AbortWindowClosed(); } emit EmergencyAbort(roundId, msg.sender); _abort(); } /// @notice Transfers the complete remaining balance to the immutable performance-fee /// recipient more than two years after the deployment entered its aborted state. function sweep() external nonReentrant { if (msg.sender != _feeRecipient) revert Unauthorized(); if (deploymentStatus != DeploymentStatus.Aborted) revert DeploymentInactive(); if (block.timestamp <= uint256(_abortedAt) + SWEEP_DELAY) revert SweepNotReady(); uint256 amount = address(this).balance; if (amount == 0) revert ZeroValue(); _sendNative(payable(_feeRecipient), amount); emit Swept(_feeRecipient, amount); } /// @notice Aborts after the resolution deadline, or after header authentication becomes /// impossible because the EIP-2935 history window has elapsed. /// @dev The header may be empty only after the history window has elapsed. function abortExpiredRound(bytes calldata entropyBlockHeaderRlp) external nonReentrant { if (deploymentStatus != DeploymentStatus.Active) revert DeploymentInactive(); if (ticketCount == 0) revert NoTickets(); if (block.timestamp < salesCloseTime) revert SalesStillOpen(); if (_entropyBlockHistoryExpired()) { if (block.timestamp <= _minimumResolutionDeadline()) { revert ResolutionWindowActive(); } } else { EntropyBlock memory entropy = _authenticatedEntropyBlock(entropyBlockHeaderRlp); (,, uint256 deadline) = _deriveSchedule(entropy.timestamp); if (block.timestamp <= deadline) revert ResolutionWindowActive(); } _abort(); } function claimTicket(uint256 ticketId, address payable recipient) external nonReentrant { Ticket storage ticket = tickets[ticketId]; if (ticket.buyer == address(0)) revert InvalidTicket(); if (msg.sender != ticket.buyer) revert Unauthorized(); if (recipient == address(0)) revert ZeroAddress(); if (ticket.paid) revert TicketAlreadyPaid(); RoundResult storage result = roundResult[ticket.roundId]; uint256 amount = 0; if (result.outcome == Outcome.Winner && result.winnerTicketId == ticketId) { amount = result.winningPayout; } else if (result.outcome == Outcome.Aborted) { amount = uint256(ticket.stake); } else { revert TicketNotClaimable(); } ticket.paid = true; outstandingClaims -= amount; _sendNative(recipient, amount); emit TicketClaimed(ticket.roundId, ticketId, result.outcome, recipient, amount); } function claimResolverBounty(address payable recipient) external nonReentrant { if (recipient == address(0)) revert ZeroAddress(); uint256 amount = resolverBountyCredit[msg.sender]; if (amount == 0) revert NoResolverBounty(); resolverBountyCredit[msg.sender] = 0; outstandingClaims -= amount; _sendNative(recipient, amount); emit ResolverBountyClaimed(msg.sender, recipient, amount); } function redeemAfterAbort(uint256 shares, address payable recipient) external nonReentrant { if (deploymentStatus != DeploymentStatus.Aborted) revert Unauthorized(); if (recipient == address(0)) revert ZeroAddress(); if (shares == 0) revert ZeroValue(); uint256 supply = totalSupply(); uint256 terminalEquity = _unreservedBalance(); if (supply == 0 || terminalEquity == 0) revert NoTerminalEquity(); uint256 assets = shares == supply ? terminalEquity : shares.mulDiv(terminalEquity, supply); if (assets == 0) revert ZeroOutput(); _burn(msg.sender, shares); _sendNative(recipient, assets); emit TerminalSharesRedeemed(msg.sender, recipient, shares, assets); } /// @notice Returns the lifecycle status, authenticating the fixed header when it is available. /// @dev Before the entropy block exists, and after its history expires, the header may be /// empty. function status(bytes calldata entropyBlockHeaderRlp) external view returns (Status) { if (deploymentStatus == DeploymentStatus.Aborted) return Status.Aborted; if (deploymentStatus == DeploymentStatus.Closed) return Status.Closed; if (block.timestamp < salesOpenTime) return Status.Liquidity; if (block.timestamp < salesCloseTime) return Status.Selling; if (ticketCount == 0) return Status.Advanceable; if (block.number <= entropyBlockNumber) return Status.WaitingForEntropyBlock; if (_entropyBlockHistoryExpired()) { return block.timestamp <= _minimumResolutionDeadline() ? Status.WaitingForAbort : Status.Abortable; } EntropyBlock memory entropy = _authenticatedEntropyBlock(entropyBlockHeaderRlp); (, uint256 targetTime, uint256 deadline) = _deriveSchedule(entropy.timestamp); if (block.timestamp > deadline) return Status.Abortable; if (block.timestamp < targetTime) return Status.WaitingForDrand; return Status.ReadyForResolution; } /// @notice Derives the immutable quicknet schedule from the authenticated entropy header. function currentDrandSchedule(bytes calldata entropyBlockHeaderRlp) external view returns (uint64 targetRound, uint256 targetTime, uint256 resolutionDeadline) { return _deriveSchedule(_authenticatedEntropyBlock(entropyBlockHeaderRlp).timestamp); } /// @notice Resolver bounty payable now after applying the current round's bankroll cap. function currentResolverBounty(bytes calldata entropyBlockHeaderRlp) external view returns (uint256) { uint256 quote = _currentResolverBountyQuote(entropyBlockHeaderRlp); return quote < Q ? quote : Q; } /// @notice Current auction quote before applying the current round's bankroll cap. function currentResolverBountyQuote(bytes calldata entropyBlockHeaderRlp) external view returns (uint256) { return _currentResolverBountyQuote(entropyBlockHeaderRlp); } function _currentResolverBountyQuote(bytes memory entropyBlockHeaderRlp) internal view returns (uint256) { (, uint256 targetTime,) = _deriveSchedule(_authenticatedEntropyBlock(entropyBlockHeaderRlp).timestamp); return _resolverBountyQuote(targetTime); } function _resolverBountyQuote(uint256 targetTime) internal view returns (uint256) { uint256 elapsed = 0; if (block.timestamp > targetTime) { elapsed = block.timestamp - targetTime; } if (elapsed > RESOLVER_BOUNTY_RAMP_SECONDS) { elapsed = RESOLVER_BOUNTY_RAMP_SECONDS; } uint256 bountyStart = roundResolverBountyStart[roundId]; uint256 bountyRange = MAX_RESOLVER_BOUNTY - bountyStart; return bountyStart + bountyRange.mulDiv(elapsed, RESOLVER_BOUNTY_RAMP_SECONDS); } function activeTicketRange() external view returns (uint256 first, uint256 last) { if (ticketCount == 0) revert NoTickets(); first = nextTicketId - ticketCount; last = nextTicketId - 1; } function previewMintShares(uint256 shares) external view returns (uint256 assets) { _requireLiquidityWindow(roundId); if (shares == 0) return 0; (assets,) = _mintQuote(shares, _unreservedBalance(), totalSupply()); } function previewRedeemShares(uint256 shares) external view returns (uint256 assets) { _requireLiquidityWindow(roundId); if (shares == 0) return 0; uint256 equity = _unreservedBalance(); uint256 supply = totalSupply(); return shares == supply ? equity : shares.mulDiv(equity, supply); } function _settleResolvedRound( EntropyBlock memory entropy, uint64 targetRound, bytes32 signatureHash, bytes32 seed ) internal { bytes32 candidateWord = keccak256(abi.encode(CANDIDATE_DOMAIN, seed)); bytes32 acceptanceWord = keccak256(abi.encode(ACCEPTANCE_DOMAIN, seed)); uint256 candidateTicketId = _candidateTicket(uint256(candidateWord) % P); Ticket storage candidate = tickets[candidateTicketId]; (uint256 exposure, uint256 payout, uint256 threshold) = _ticketEconomics(candidate.targetPayout); bool accepted = exposure == 0 || uint128(uint256(acceptanceWord)) < threshold; Outcome outcome = accepted ? Outcome.Winner : Outcome.NoWinner; uint256 winnerTicketId = accepted ? candidateTicketId : 0; uint128 winningPayout = accepted ? uint128(payout) : 0; uint256 resolvedRoundId = roundId; uint256 roundP = P; uint256 roundQ = Q; uint256 roundTicketCount = ticketCount; roundResult[resolvedRoundId] = RoundResult(outcome, winnerTicketId, winningPayout); if (accepted) outstandingClaims += payout; uint256 resolverBountyStart = roundResolverBountyStart[resolvedRoundId]; uint256 targetTime = DRAND_GENESIS_TIME + (uint256(targetRound) - 1) * DRAND_PERIOD_SECONDS; uint256 resolverBountyQuote = _resolverBountyQuote(targetTime); uint256 resolverBounty = resolverBountyQuote < roundQ ? resolverBountyQuote : roundQ; roundResolverBountyQuote[resolvedRoundId] = resolverBountyQuote; roundResolverBounty[resolvedRoundId] = resolverBounty; resolverBountyCredit[msg.sender] += resolverBounty; outstandingClaims += resolverBounty; emit RoundResolved( resolvedRoundId, entropy.number, entropy.timestamp, entropy.hash, entropy.prevRandao, targetRound, signatureHash, seed, roundP, roundQ, roundTicketCount, outcome, candidateTicketId, winnerTicketId, winningPayout, resolverBountyStart, resolverBountyQuote, resolverBounty, msg.sender ); uint256 nextResolverBountyStart = resolverBountyQuote.mulDiv( RESOLVER_BOUNTY_CARRYOVER_NUMERATOR, RESOLVER_BOUNTY_CARRYOVER_DENOMINATOR ); uint256 nextQ = _unreservedBalance(); if (nextQ == 0) { Q = 0; _enterAbortedState(); return; } _openNextRound(nextQ, nextResolverBountyStart); } /// @dev Mints a global fee only above the last post-fee NAV high-water mark. Minting shares /// leaves every wei in the bankroll and avoids adding another pull-payment liability. function _crystallizePerformanceFee(uint256 resolvedRoundId) internal { uint256 equity = _unreservedBalance(); uint256 supply = totalSupply(); uint256 hurdleAssets = supply.mulDiv(highWaterMark, PAYOUT_SCALE, Math.Rounding.Ceil); if (equity <= hurdleAssets) return; uint256 profitAssets = equity - hurdleAssets; uint256 feeAssets = profitAssets.mulDiv(PERFORMANCE_FEE_BPS, BPS_SCALE); if (feeAssets == 0) return; // x / (S + x) * equity = feeAssets. uint256 feeShares = supply.mulDiv(feeAssets, equity - feeAssets); if (feeShares == 0) return; _mint(_feeRecipient, feeShares); highWaterMark = equity.mulDiv(PAYOUT_SCALE, supply + feeShares, Math.Rounding.Ceil); emit PerformanceFeeMinted( resolvedRoundId, _feeRecipient, profitAssets, feeAssets, feeShares, highWaterMark ); } /// @dev Below the global HWM, a primary mint prepays 10% of its fee-free recovery as extra /// bankroll equity. Only the requested shares are minted, so existing holders benefit /// without any per-investor basis or fee-credit accounting. function _mintQuote(uint256 shares, uint256 equity, uint256 supply) internal view returns (uint256 assets, uint256 premiumAssets) { uint256 baseAssets = shares.mulDiv(equity, supply, Math.Rounding.Ceil); uint256 hwmAssets = shares.mulDiv(highWaterMark, PAYOUT_SCALE); if (hwmAssets <= baseAssets) return (baseAssets, 0); premiumAssets = (hwmAssets - baseAssets).mulDiv(PERFORMANCE_FEE_BPS, BPS_SCALE); assets = baseAssets + premiumAssets; } function _abort() internal { uint256 refundableAmount = uint256(P); outstandingClaims += refundableAmount; roundResult[roundId] = RoundResult(Outcome.Aborted, 0, 0); _enterAbortedState(); emit RoundAborted(roundId, refundableAmount); } function _enterAbortedState() internal { deploymentStatus = DeploymentStatus.Aborted; _abortedAt = uint64(block.timestamp); } function _openNextRound(uint256 nextQ, uint256 resolverBountyStart) internal { if (nextQ > type(uint128).max) revert ValueOutOfRange(); _crystallizePerformanceFee(roundId); ++roundId; roundResolverBountyStart[roundId] = resolverBountyStart; salesOpenTime = block.timestamp + LIQUIDITY_WINDOW_SECONDS; salesCloseTime = salesOpenTime + ROUND_SECONDS; entropyBlockNumber = block.number + ENTROPY_BLOCK_OFFSET; P = 0; Q = uint128(nextQ); ticketCount = 0; _minimumResolutionDeadline(); emit RoundOpened( roundId, nextQ, salesOpenTime, salesCloseTime, entropyBlockNumber, resolverBountyStart ); } function _deriveSchedule(uint256 entropyTimestamp) internal pure returns (uint64 targetRound, uint256 targetTime, uint256 resolutionDeadline) { uint256 minimumDrandTime = entropyTimestamp + DRAND_DELAY_SECONDS; if (minimumDrandTime < DRAND_GENESIS_TIME) revert InvalidSchedule(); uint256 round = ((minimumDrandTime - DRAND_GENESIS_TIME) / DRAND_PERIOD_SECONDS) + 2; if (round > type(uint64).max) revert InvalidSchedule(); targetRound = uint64(round); targetTime = DRAND_GENESIS_TIME + (round - 1) * DRAND_PERIOD_SECONDS; if (targetTime <= minimumDrandTime) revert InvalidSchedule(); resolutionDeadline = targetTime + DRAND_RESOLUTION_WINDOW_SECONDS; } function _authenticatedEntropyBlock(bytes memory headerRlp) internal view returns (EntropyBlock memory entropy) { uint256 targetBlock = entropyBlockNumber; if (block.number <= targetBlock) revert EntropyBlockNotAvailable(); if (block.number - targetBlock > EIP2935_HISTORY_WINDOW) { revert EntropyBlockHistoryExpired(); } if (headerRlp.length == 0 || headerRlp.length > MAX_ENTROPY_BLOCK_HEADER_BYTES) { revert InvalidEntropyBlockHeader(); } bytes32 canonicalHash = Blockhash.blockHash(targetBlock); if (canonicalHash == bytes32(0) || keccak256(headerRlp) != canonicalHash) { revert InvalidEntropyBlockHeader(); } Memory.Slice[] memory fields = RLP.decodeList(headerRlp); if ( fields.length < MIN_EXECUTION_HEADER_FIELDS || fields[HEADER_DIFFICULTY_INDEX].readUint256() != 0 || fields[HEADER_BLOCK_NUMBER_INDEX].readUint256() != targetBlock || fields[HEADER_PREVRANDAO_INDEX].length() != RLP_BYTES32_ENCODED_LENGTH ) { revert InvalidEntropyBlockHeader(); } uint256 entropyTimestamp = fields[HEADER_TIMESTAMP_INDEX].readUint256(); uint256 minimumTimestamp = salesCloseTime + ENTROPY_MIN_DELAY_SECONDS; if (entropyTimestamp < minimumTimestamp) { revert EntropyBlockTooEarly(minimumTimestamp, entropyTimestamp); } entropy = EntropyBlock({ number: targetBlock, timestamp: entropyTimestamp, hash: canonicalHash, prevRandao: fields[HEADER_PREVRANDAO_INDEX].readBytes32() }); } function _entropyBlockHistoryExpired() internal view returns (bool) { uint256 targetBlock = entropyBlockNumber; return block.number > targetBlock && block.number - targetBlock > EIP2935_HISTORY_WINDOW; } function _minimumResolutionDeadline() internal view returns (uint256 deadline) { // This lower bound prevents a history-expiry refund from preempting any header that the // contract would accept. Ethereum's block cadence keeps the real deadline earlier than // history expiry; this guard is defense in depth for an incompatible faster-block chain. (,, deadline) = _deriveSchedule(salesCloseTime + ENTROPY_MIN_DELAY_SECONDS); } function _candidateTicket(uint256 position) internal view returns (uint256) { uint256 low = nextTicketId - ticketCount; uint256 high = nextTicketId; while (low < high) { uint256 mid = low + ((high - low) >> 1); if (tickets[mid].cumulativeEnd > position) high = mid; else low = mid + 1; } return low; } function _ticketEconomics(uint128 targetPayout) internal view returns (uint256 exposure, uint256 payout, uint256 threshold) { uint256 playerPot = P; uint256 bankroll = Q; if (targetPayout > playerPot) { uint256 requestedExposure = uint256(targetPayout) - playerPot; uint256 exposureCap = bankroll / 2; uint256 maximumBounty = MAX_RESOLVER_BOUNTY < bankroll ? MAX_RESOLVER_BOUNTY : bankroll; uint256 bountyReservedExposure = bankroll - maximumBounty; if (bountyReservedExposure < exposureCap) exposureCap = bountyReservedExposure; exposure = requestedExposure < exposureCap ? requestedExposure : exposureCap; } payout = playerPot + exposure; if (exposure == 0) return (0, payout, PAYOUT_SCALE); uint256 numeratorFactor = playerPot * (bankroll - exposure); uint256 denominator = bankroll * payout; threshold = numeratorFactor.mulDiv(PAYOUT_SCALE, denominator); } function _verifyDrandSignature(bytes memory signature, uint64 targetRound) internal view virtual returns (bool) { return QuicknetBLS.verify(signature, targetRound); } function _requireActiveRound(uint256 expectedRoundId) internal view { if (deploymentStatus != DeploymentStatus.Active) revert DeploymentInactive(); if (expectedRoundId != roundId) revert WrongRound(expectedRoundId, roundId); } function _requireLiquidityWindow(uint256 expectedRoundId) internal view { _requireActiveRound(expectedRoundId); if (block.timestamp >= salesOpenTime) revert LiquidityWindowClosed(); } function _requireSuccessorCapacity(uint256 playerPot) internal view { uint256 maximumSuccessor = uint256(Q) + playerPot; if (maximumSuccessor > type(uint128).max) revert ValueOutOfRange(); } function _unreservedBalance() internal view returns (uint256) { return address(this).balance - outstandingClaims; } function _sendNative(address payable recipient, uint256 amount) internal { (bool ok,) = recipient.call{ value: amount }(""); if (!ok) revert NativeTransferFailed(); } function _update(address from, address to, uint256 value) internal override { if (to == address(this) && from != address(0)) { revert CannotTransferSharesToLottery(); } super._update(from, to, value); } }