Placement and rounds
roundOffset = floor(deploymentBlock / 10,000)
roundIdForBlock(blockNumber) =
max(1, ceil(blockNumber / 10,000) - roundOffset)
latestRoundIdForBlock(blockNumber) = roundIdForBlock(blockNumber) + 9
closeBlockForRound(roundId) = (roundOffset + roundId) * 10,000
randomnessBlockForRound(roundId) = closeBlockForRound(roundId) + 132
lastResolutionBlockForRound(roundId) = randomnessBlockForRound(roundId) + 8,191
getRoundInfo(roundId) → validRoundId, hasPositions,
closeBlock, round
placeBet(uint64 roundId, uint96 targetPayout,
uint32 maxEdgeQ32, address beneficiary,
bytes32 positionCommitment)
payable returns (uint64 positionIndex, uint256 rangeStart,
uint256 aggregateStake)The constructor captures roundOffset, so every deployment starts at round 1 without moving targets away from exact 10,000-block boundaries. roundId may be any of the next 10 open boundaries. Positions in multiple rounds coexist, and placement reserves no bankroll. Each position stores its immutable target and 2^32 fixed-point edge cap in two storage slots. The opaque commitment is recorded only in BetPlaced. Stakes must be positive; stake and target fit uint96, the edge cap fits uint32, and total accounted assets fit uint128. New bets require a funded bankroll, and an overdue oldest round blocks placement until it expires.
Candidate-specific payout
Qᵢ = max(aggregateStake, candidate.targetPayout)
maxGap = floor(settlementBankroll × candidate.maxEdgeQ32 ÷ 2^32)
Gᵢ = min(Qᵢ − aggregateStake, maxGap)
Pᵢ = aggregateStake + Gᵢ
actualEdge = Gᵢ ÷ settlementBankrollInvestments, earlier outcomes, later stakes, and eligible redemptions may change the final quote. Placement reserves the stake but no share of the bankroll for a future payout. Quote new positions against the aggregate including their proposed stake.
Delayed draw
seed = keccak256(abi.encode(
LUCKOTTO_DRAW_V1, deploymentDomain,
roundId, closeBlock, randomnessBlock, aggregateStake,
settlementBankroll, prevRandao, drandRound,
sha256(drandSignature)
))
candidatePoint = H_candidate(seed) % aggregateStake
coveragePoint = H_coverage(seed, candidateIndex) % Pᵢ
riskPoint = H_risk(seed, candidateIndex) % settlementBankroll
winner when coveragePoint < aggregateStake and
riskPoint < settlementBankroll - GᵢBetting is open through C. The authenticated header at R = C + 132 supplies prevRandao and a timestamp; the first Quicknet round at or after that timestamp plus 20 minutes supplies the later input. Settlement requires block.number > R and the complete proof. The signature uses the pinned Quicknet key and canonical 96-byte uncompressed G1 encoding. These block and timestamp checks do not themselves prove Ethereum consensus finality.
Settlement and expiry
settle(uint64 roundId, uint64 candidatePosition,
bytes rlpHeader, bytes drandSignature) returns (bool won)
claimWinner(uint64 roundId)
expireRound(uint64 roundId)Settlement snapshots bankroll and records a payout without calling its beneficiary. Winner claims require block.timestamp >= settledAt + CLAIM_DELAY, where CLAIM_DELAY = 3 hours; an earlier claim reverts with ClaimNotReady. Candidate failure adds stakes to bankroll; success deducts only the payout gap. Settle with a complete proof within the 8,191-block history window. After the deadline, expiry transfers all of an unresolved round’s stakes from reserves into the bankroll. There are no expired-stake refunds. There is no automatic settlement or resolver reward. The proof deadline is inclusive: R + HISTORY_WINDOW_BLOCKS; expiry is available only afterward. A scheduled round with no positions has no stored state and is skipped without a transaction. Bankroll snapshots and finalization remain chronological: finalize earlier pending rounds before settling or expiring later ones.
Emergency inconsistency
settle also accepts complete proofs for already resolved rounds. It authenticates the original header against the saved randomnessBlockHash and recomputes the result with the stored settlement bankroll. A different selected position or win/no-win result permanently sets emergencyInconsistency(). Rechecking remains possible after the original settlement deadline and after payment; matching outcomes do not restart the claim delay.
The halt blocks every state-changing action for all senders, including the owner, except owner-only drain(recipient). Reads remain available. Emergency drain transfers all cash immediately without requiring freeze or the 365-day delay. Claims, refunds, approvals, transfers, settlement and freeze remain blocked. Settlement accounting takes effect immediately; the 3-hour delay applies to winner payments. The watcher independently checks official drand results and submits a proof when its computed outcome conflicts.
Bankroll and administration
invest(minSharesOut) → mint LUCKOTTO
previewInvestment(assets) → shares
redeemableShares(investor) → eligible shares
previewRedeem(shares) → assets
redeem(shares, recipient, minAssetsOut) → burn and send ETH
transfer / approve / transferFrom
bankrollBlockedRound()
freeze()
claimRefund(roundId, positionIndex)
refundClaimed(roundId, positionIndex) → bool
shutdownComplete() → successfully frozen
drain(recipient)Ordinary LP withdrawals are available only in blocks 1–260 after each 10,000-block boundary, after every earlier closed round has settled or expired. The closing boundary block is outside the window. Empty rounds do not bypass it, and late finalization does not extend it. Active eligibility requires (block.number - 1) % ROUND_BLOCKS < WITHDRAWAL_WINDOW_BLOCKS, with WITHDRAWAL_WINDOW_BLOCKS = 260, and no unresolved closed round. A closed withdrawal window reverts with RedemptionWindowClosed; a pending closed round blocks bankroll operations.
Deposits pause after a positioned round closes until finalization. previewInvestment quotes shares rounded down for the full deposit and returns zero after full shutdown redemption. invest(minSharesOut) protects the share output; zero-share investments revert and rounding remains in the bankroll. minAssetsOut protects redemption value. Ordinary LPs can redeem all their eligible shares.
The owner funds at least 1 ETH as the constructor seed, and all owner-held shares stay locked against redemption and transfer until shutdownComplete(): a successful atomic freeze. Outside an emergency halt, owner-only freeze() requires every closed round to settle or expire first, irreversibly stops betting and investment, and cancels remaining open rounds. All shares unlock immediately. Anyone can claim each cancelled stake once for its fixed beneficiary; refunds cannot be redirected and stay separately reserved. Ordinary LP transfers remain available. Use redeemableShares(account) for eligibility. During an inconsistency halt, or otherwise after 365 days from freeze, owner-only drain(recipient) transfers all remaining cash, including backing for unpaid winnings, refunds, and bankroll shares, without changing accounting state. Emergency drain bypasses freeze and its delay; every other state-changing action stays blocked, including owner calls.
Security and assurance evidence
The repository records cryptographic vectors, independent implementations, execution-client comparisons, arithmetic models, and contract and application tests. This is automated and AI-assisted evidence; an independent professional audit remains outstanding. This production deployment uses real ETH; settlement and claims require transactions.