Lucky Lotto Technical Specification
Lucky Lotto sells weighted lottery claims over a 100-number pool. A player requests a set of numbers, pays a quoted fair budget plus an explicit partner fee, and receives an allocated non-overlapping claim bundle after the payment confirms. The prize pool for a round is the sum of allocated fair prices. Fees and unused paid budget are not part of the prize pool.
This document is self-contained and describes the current technical and mathematical model used by the calculator, API, worker, and public docs.
Current Constants
| Name | Value | Meaning |
|---|---|---|
LUCKY_POOL_SIZE | 100 | Numbers are 0 through 99. |
LUCKY_HITS_NEEDED | 6 | The atomic winning event is a six-number claim. |
LUCKY_MIN_PICK_SIZE | 6 | Minimum requested or allocated order size. |
LUCKY_MAX_PICK_SIZE | 21 | Maximum requested or allocated order size. |
LUCKY_COLDEST_CLAIM_PRICE_SATS | 100 | Protocol fair price for [94,95,96,97,98,99]. |
LUCKY_HOTTEST_CLAIM_PRICE_SATS | 10000000 | Protocol fair price for [0,1,2,3,4,5]. |
LUCKY_BIAS | 1.9701168164392615 | Exponential weight parameter solved from the hot/cold claim ratio. |
| Hot/cold claim ratio | 100000 | Hottest six-number claim has 100,000x the actual draw probability of the coldest six-number claim. |
| Total atomic claims | choose(100, 6) = 1192052400 | Every possible six-number subset of the pool. |
| Max claims in one order | choose(21, 6) = 54264 | A 21-number order contains 54,264 internal atomic claims. |
Prices are stored and settled in integer sats. The calculator display currently shows display_dollars = sats / 1000, so 100 sats displays as $0.10 and 10000000 sats displays as $10,000.00.
Number Weights
Every number has an exponential draw rate:
N = 100
b = 1.9701168164392615
rate(n) = exp(b * (N - 1 - n) / (N - 1))For the current constants:
rate(0) = 7.171514190168797
rate(99) = 1
sum(rate(0)..rate(99)) = 314.2196855938824The first draw probability for a single number is:
P(first draw is n) = rate(n) / total_rateThis means number 0 is about 7.17x as likely as number 99 on the first draw. That is not the same as the six-number claim ratio. The six-number hot/cold claim ratio is 100000x because the event is "all first six drawn numbers are inside this exact six-number set" and the bias is solved against that full without-replacement probability.
Weighted Draw Without Replacement
A round draw is modelled as six weighted draws without replacement. Once a number is drawn, its rate is removed from the remaining total.
For an ordered sequence d = [d1,d2,d3,d4,d5,d6] with no duplicates:
P(d) =
rate(d1) / R
* rate(d2) / (R - rate(d1))
* rate(d3) / (R - rate(d1) - rate(d2))
* ...
* rate(d6) / (R - rate(d1) - ... - rate(d5))R is the total rate of all 100 numbers.
An atomic claim is unordered, so the probability of a six-number claim C is the sum of all ordered permutations of C:
P(C wins) = sum(P(permutation) for every permutation of C)There are 6! = 720 permutations for one atomic claim. The implementation uses dynamic programming rather than explicitly enumerating all permutations for larger selected sets.
Order Probability
A player can request and receive m numbers where 6 <= m <= 21. That allocated set represents every internal six-number claim contained in the selected numbers.
claim_count(S) = choose(size(S), 6)
P(S wins) = P(the first 6 drawn numbers are all inside S)Equivalently:
P(S wins) = sum(P(C wins) for every six-number subset C of S)The direct dynamic-programming recurrence tracks which selected numbers have already been hit. For a selected set S = [s0..s(m-1)], define each selected number as a bit in a mask.
states[0] = { probability: 1, selected_rate: 0 }
for pick in 1..6:
next_states = empty
for each mask,state in states:
remaining_rate = total_rate - state.selected_rate
for each selected index i not in mask:
next_mask = mask with i set
next_probability = state.probability * rate(si) / remaining_rate
next_selected_rate = state.selected_rate + rate(si)
add next_probability to next_states[next_mask]
states = next_states
P(S wins) = sum(state.probability for state in states)This recurrence only follows paths where every draw is inside the selected set. It intentionally does not track misses because a miss means the order cannot win the "first six drawn numbers are inside S" event.
Combination Counts
The number of independent internal six-number claims grows quickly as pick size increases.
| Pick size | Independent claims |
|---|---|
| 6 | 1 |
| 7 | 7 |
| 8 | 28 |
| 9 | 84 |
| 10 | 210 |
| 11 | 462 |
| 12 | 924 |
| 13 | 1716 |
| 14 | 3003 |
| 15 | 5005 |
| 16 | 8008 |
| 17 | 12376 |
| 18 | 18564 |
| 19 | 27132 |
| 20 | 38760 |
| 21 | 54264 |
The full 100-number pool contains choose(100, 6) = 1192052400 atomic claims.
Bias Solve
The exponential bias is chosen so that:
P([0,1,2,3,4,5] wins) / P([94,95,96,97,98,99] wins) = 100000That ratio is solved over the six-number without-replacement claim probability, not over the single-number rate function. The resulting value is:
LUCKY_BIAS = 1.9701168164392615This gives:
P(coldest six-number claim) = 1.06049201495e-12
P(hottest six-number claim) = 1.06049201495e-7So the coldest atomic claim is about 1 in 942,958,538,019, while the hottest atomic claim is about 1 in 9,429,585.
Fair Price
The coldest atomic claim defines the base protocol price:
coldest_claim = [94,95,96,97,98,99]
coldest_price_sats = 100
coldest_probability = P(coldest_claim wins)Every other selected set is priced in direct proportion to its actual win probability:
fair_price_sats(S) =
round(coldest_price_sats * P(S wins) / coldest_probability)The implementation clamps the integer result to at least 1 sat, though the current constants make the coldest valid claim 100 sats.
This pricing rule is the core fairness rule: a claim with x times the raw weighted draw probability costs x times as much.
Reference Price And Probability Table
| Selection | Claims | Probability | Chance | Fair price sats | Calculator display |
|---|---|---|---|---|---|
[94,95,96,97,98,99] | 1 | 1.06049201495e-12 | 1 in 942,958,538,019 | 100 | $0.10 |
[47,48,49,50,51,52] | 1 | 3.14200143824e-10 | 1 in 3,182,684,730 | 29,628 | $29.63 |
[0,1,2,3,4,5] | 1 | 1.06049201495e-7 | 1 in 9,429,585 | 10,000,000 | $10,000.00 |
[79..99] | 54,264 | 1.46932098788e-7 | 1 in 6,805,865 | 13,855,088 | $13,855.09 |
[39..59] | 54,264 | 0.0000187683615207 | 1 in 53,281 | 1,769,778,674 | $1,769,778.67 |
[0..20] | 54,264 | 0.00232512056421 | 1 in 430 | 219,249,228,794 | $219,249,228.79 |
The 21-number examples are expensive because they contain 54,264 internal atomic claims.
Expected Value
The settlement samples only from allocated sold orders. If a round has allocated orders O1..Ok, the prize pool is:
pool_sats = sum(allocated_price_sats(Oi))The winner is sampled by allocated fair price:
P(Oi wins settlement) = allocated_price_sats(Oi) / pool_satsThe pre-fee EV for every allocated order is therefore zero:
EV(Oi) =
P(Oi wins settlement) * pool_sats
- allocated_price_sats(Oi)
EV(Oi) =
allocated_price_sats(Oi) / pool_sats * pool_sats
- allocated_price_sats(Oi)
EV(Oi) = 0The explicit partner fee makes player EV negative by the displayed fee amount. The fee is explicit and is not hidden inside the odds.
Why Settlement Samples Sold Orders
The raw weighted draw model defines fair relative prices. It is not used as the final winner picker across the entire 100-number space because many possible claims are unsold. If settlement drew the first six weighted numbers globally, the round would often land on an unsold claim. The product then has to choose between no winner, rollover, prize retention, or secondary winner rules, each of which changes EV.
Lucky Lotto instead uses the raw weighted draw model to price claims, then samples among sold allocated orders by fair price. This preserves exact zero EV before fees for every sold order.
The public winning claim shown after settlement is sampled inside the winning order for audit and display. It does not change which order won.
Quote And Payment Economics
A quote contains:
quote_price_sats = fair_price_sats(requested_numbers)
quote_fee_sats = ceil(quote_price_sats * partner_fee_rate)
quote_total_sats = quote_price_sats + quote_fee_satsThe partner fee rate is stored as partner_sites.house_edge, but it is now an explicit fee percent rather than hidden odds distortion.
Payment rules:
- A new order receives one dedicated Bitcoin deposit address.
- No unpaid order reserves any claims.
- An underpaid order stays
pending_payment. - Once confirmed payments reach
quote_total_sats, the worker attempts allocation. - The allocator may assign a same-size set cheaper than the quoted requested set if the requested set conflicts.
- The allocated fair price enters the prize pool.
- The explicit fee and absorbed unused payment budget become partner revenue.
After allocation:
allocated_price_sats = fair_price_sats(allocated_numbers)
explicit_fee_sats = ceil(allocated_price_sats * partner_fee_rate)
absorbed_unspent_sats =
paid_sats - allocated_price_sats - explicit_fee_satsBecause allocated_price_sats <= quote_price_sats, the explicit fee on the allocation is less than or equal to the quoted fee. Any leftover budget, including overpayment, is absorbed into partner revenue.
The prize pool is:
prize_pool_sats =
sum(allocated_price_sats for orders allocated in the round)It does not include explicit fees or absorbed unspent sats.
Allocation
Requested numbers are hints, not guaranteed claims. Allocation occurs only after the order is fully paid and sufficiently confirmed.
The allocator evaluates a deterministic set of same-size candidates and returns the best non-overlapping candidate it finds within the paid fair budget.
The allocator searches for a same-size candidate set with:
size(candidate) = size(requested_numbers)
fair_price_sats(candidate) <= quote_price_sats
candidate does not overlap any allocated order by 6 or more numbersCandidate ranking is:
- Maximize allocated fair price.
- Maximize overlap with requested numbers.
- Minimize total numeric distance from requested numbers.
- Use deterministic lexicographic order as the final tie-break.
Price-first ranking matters. If a requested hint is mostly unavailable, the system prefers the most valuable still-affordable candidate before preserving hint overlap. That keeps the allocated prize contribution as close as possible to the paid fair budget within the allocator's search space.
If no acceptable candidate exists in the current round, the order remains paid and is retried in future open rounds until it allocates or is manually cancelled.
Non-Overlapping Claim Rule
Two allocated sets conflict if and only if they share at least six numbers.
conflict(A, B) = size(intersection(A, B)) >= 6Proof:
- If
size(intersection(A, B)) >= 6, then there exists a six-number subset contained by both sets. Both orders would claim the same atomic six-number outcome. - If
size(intersection(A, B)) < 6, then no six-number subset can be contained by both sets. They share no atomic claims.
This is why the database stores allocated numbers, not every internal claim. A 21-number order represents 54,264 internal claims, but conflict detection only needs the 21 allocated numbers.
The current normalized table is:
CREATE TABLE ticket_order_numbers (
order_id uuid NOT NULL REFERENCES ticket_orders(id),
round_number integer NOT NULL REFERENCES rounds(round_number),
number integer NOT NULL,
PRIMARY KEY (order_id, number)
);
CREATE INDEX ticket_order_numbers_round_number_idx
ON ticket_order_numbers(round_number, number, order_id);A conflict check is:
SELECT existing.order_id
FROM ticket_order_numbers existing
JOIN ticket_orders orders ON orders.id = existing.order_id
WHERE existing.round_number = $1
AND orders.status IN ('allocated', 'settled')
AND existing.number = ANY($2)
GROUP BY existing.order_id
HAVING count(*) >= 6
LIMIT 1;That query remains small because each order contributes only 6-21 rows.
Round Lifecycle
- A round is open over a block-height interval.
- Players create orders against the earliest open eligible round.
- Confirmed payments are attached to dedicated order addresses.
- Fully paid orders are allocated into the earliest open eligible round.
- Once the closing block is known, the worker records the closing block hash.
- If no allocated orders exist, the round settles with no winner and no prize.
- If allocated orders exist, the worker picks one winning order by allocated fair price.
- The worker samples one internal six-number claim inside the winning order by atomic claim fair price.
- The round stores
winning_order_id,winning_claim_numbers,closing_block_hash,settled_at, and laterpayout_txid.
Deterministic Settlement
Settlement randomness is derived from the closing block hash. The worker uses domain-separated SHA-256 and rejection sampling to avoid modulo bias.
uniformBigInt(seed, label, modulo):
range = 2^256
limit = range - (range mod modulo)
for counter in 0..999:
value = sha256(seed + ":" + label + ":" + counter)
if value < limit:
return value mod moduloThe winning order uses:
target = uniformBigInt(closing_block_hash, "order", prize_pool_sats)Allocated orders are sorted by ID and walked cumulatively by allocated_price_sats.
The public winning claim uses:
claims = every six-number subset of winning_order.allocated_numbers
weight(claim) = fair_price_sats(claim)
target = uniformBigInt(closing_block_hash, "claim", sum(weight(claim)))Claims are walked cumulatively by atomic claim fair price. The result is stored as rounds.winning_claim_numbers.
The "order" and "claim" labels make the two samples independent domains even though they use the same closing block hash.
Database Shape
The core Lucky tables are:
| Table | Purpose |
|---|---|
rounds | Round schedule, closing block hash, winner, winning claim numbers, payout txid, settlement timestamp. |
ticket_orders | One Lucky order: requested hints, quote, payment totals, allocation, fees, and status. |
ticket_order_numbers | Normalized allocated numbers for overlap checks and public ledger queries. |
deposit_addresses | One dedicated Bitcoin address per order. |
payments | Confirmed outputs to order deposit addresses. |
mempool_payments | Unconfirmed payment hints for UI status. |
partner_sites | Partner fee rate, owner, deletion state, and API key. |
partner_balances | Derived partner revenue from explicit fees and absorbed unspent budget. |
Lucky order state is stored in ticket_orders; normalized allocated numbers are stored in ticket_order_numbers.
Public API Shape
The Lucky order flow uses these API surfaces:
| Route | Purpose |
|---|---|
POST /orders/quote | Return fair budget, explicit fee, total due, probability, and claim count for requested numbers. |
POST /orders | Create an order and dedicated deposit address. |
GET /orders/:id | Return one public order. |
GET /orders?partnerId&playerIdentifier | Return player order history. |
GET /bootstrap | Return partner info, Lucky constants, player orders, public orders, prize pool, and fee rate. |
The website also exposes public round and order views for audit.
Verification Invariants
A verifier should check these invariants for a settled round:
- Every allocated order has
allocated_price_sats = fair_price_sats(allocated_numbers). - Every allocated order has
explicit_fee_sats = ceil(allocated_price_sats * partner_fee_rate). - No pair of allocated orders in the same round has an intersection of six or more numbers.
prize_pool_sats = sum(allocated_price_sats)for the round.- The winning order is the deterministic weighted sample from the closing block hash and allocated order prices.
- The winning claim is a deterministic weighted sample of internal six-number claims inside the winning order.
- The winning claim is contained in the winning order's allocated numbers.
- Empty rounds have no winner and no prize.
Operational Notes
- Order deposit addresses are dedicated and payable indefinitely.
- Underpaid orders do not reserve any claims.
- Paid but unallocated orders retry into future rounds.
- Partner revenue is derived from explicit fees plus absorbed unspent payment budget.
- Public ledgers expose allocated numbers, fair price, fee, absorbed unspent budget, status, partner, and player identifiers.
- The payout destination is the winning order partner's payout address.
- Lucky constants are environment-level protocol constants, not per-round database fields.
Minimal Pseudocode
quote(numbers, fee_rate):
normalized = sort(unique(numbers))
assert 6 <= size(normalized) <= 21
probability = ticket_probability(normalized)
price = round(100 * probability / P([94,95,96,97,98,99]))
fee = ceil(price * fee_rate)
return { price, fee, total: price + fee }
allocate(order):
candidates = deterministic_candidate_sets_same_size_as(order.requested_numbers)
candidates = filter(candidates, price(candidate) <= order.quote_price_sats)
candidates = filter(candidates, no_overlap_by_6(candidate, allocated_orders_in_round))
sort by price desc, overlap desc, distance asc, lexicographic asc
return first candidate
settle(round, closing_block_hash):
orders = allocated orders in round
pool = sum(order.allocated_price_sats)
if pool == 0:
close with no winner
winner = weighted_sample(orders, order.allocated_price_sats, closing_block_hash, "order")
claim = weighted_sample(internal_claims(winner), fair_price_sats(claim), closing_block_hash, "claim")
store winner and claim