Placement and rounds
roundOffset = deploymentBlock / 10_000
roundIdForBlock(blockNumber) =
max(1, blockNumber / 10_000 + 1 - roundOffset)
latestRoundIdForBlock(blockNumber) = roundIdForBlock(blockNumber) + 9
targetBlockForRound(roundId) = (roundOffset + roundId) * 10_000
getRoundInfo(roundId) → validRoundId, hasPositions,
targetBlock, 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 ten 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.
Candidate-specific payout
Rᵢ = max(aggregateStake, candidate.targetPayout)
maxGap = floor(settlementBankroll × candidate.maxEdgeQ32 ÷ 2^32)
Gᵢ = min(Rᵢ − aggregateStake, maxGap)
Pᵢ = aggregateStake + Gᵢ
actualEdge = Gᵢ ÷ settlementBankrollThe contract uses bankroll at the target cutoff. Investments and earlier outcomes may change it, but active divestment cannot; later-round payouts remain variable and uncollateralized.
V1 draw
seed = keccak256(abi.encode(
LUCKOTTO_DRAW_V1, deploymentDomain,
roundId, targetBlock, 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ᵢWhen bankroll is zero, payout equals aggregate stake and the risk gate passes automatically. The caller supplies the unique interval containing candidatePoint. The canonical header is authenticated through EIP-2935, and its timestamp selects Quicknet randomness at least 30 minutes later.
Settlement and expiry
settle(uint64 roundId, uint64 candidatePosition,
bytes rlpHeader, bytes drandSignature) returns (bool won)
claimWinner(uint64 roundId)
claimWinnerTo(uint64 roundId, address payable recipient)
expireRound(uint64 roundId)Settlement snapshots bankroll and records a payout without calling its beneficiary. Candidate failure adds stakes to bankroll; success deducts only the payout gap. After the 8,191-block proof deadline, expiry forfeits unresolved stakes to the bankroll. A scheduled round with no positions has no stored state and is skipped without a transaction. Winners bear the burden of timely proof submission, while earlier positioned rounds must finalize before later rounds can establish their bankroll snapshot.
Bankroll and administration
invest() → mint LUCKOTTO
divest(shares, recipient) → burn and send ETH
transfer / approve / transferFrom
bankrollBlockedRound()
freeze()
extendSunset()
drain(recipient)Active investment pauses from a positioned target through settlement or expiry; active divestment is never available. freeze() irreversibly stops betting and investment, after which divestment waits for every pending round to finalize. The owner can freeze at any time; anyone can freeze after renewable sunsetAt. Transfers remain available but move unresolved exposure and can trade against stale NAV. One year after freezing, owner-only drain(recipient) transfers all cash to a selected external recipient without changing accounting state.
Security status
The code has automated unit, fuzz, invariant, static-analysis, and browser tests. Test ETH has no monetary value.