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

NameValueMeaning
LUCKY_POOL_SIZE100Numbers are 0 through 99.
LUCKY_HITS_NEEDED6The atomic winning event is a six-number claim.
LUCKY_MIN_PICK_SIZE6Minimum requested or allocated order size.
LUCKY_MAX_PICK_SIZE21Maximum requested or allocated order size.
LUCKY_COLDEST_CLAIM_PRICE_SATS100Protocol fair price for [94,95,96,97,98,99].
LUCKY_HOTTEST_CLAIM_PRICE_SATS10000000Protocol fair price for [0,1,2,3,4,5].
LUCKY_BIAS1.9701168164392615Exponential weight parameter solved from the hot/cold claim ratio.
Hot/cold claim ratio100000Hottest six-number claim has 100,000x the actual draw probability of the coldest six-number claim.
Total atomic claimschoose(100, 6) = 1192052400Every possible six-number subset of the pool.
Max claims in one orderchoose(21, 6) = 54264A 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.2196855938824

The first draw probability for a single number is:

P(first draw is n) = rate(n) / total_rate

This 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 sizeIndependent claims
61
77
828
984
10210
11462
12924
131716
143003
155005
168008
1712376
1818564
1927132
2038760
2154264

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) = 100000

That 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.9701168164392615

This gives:

P(coldest six-number claim) = 1.06049201495e-12
P(hottest six-number claim) = 1.06049201495e-7

So 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

SelectionClaimsProbabilityChanceFair price satsCalculator display
[94,95,96,97,98,99]11.06049201495e-121 in 942,958,538,019100$0.10
[47,48,49,50,51,52]13.14200143824e-101 in 3,182,684,73029,628$29.63
[0,1,2,3,4,5]11.06049201495e-71 in 9,429,58510,000,000$10,000.00
[79..99]54,2641.46932098788e-71 in 6,805,86513,855,088$13,855.09
[39..59]54,2640.00001876836152071 in 53,2811,769,778,674$1,769,778.67
[0..20]54,2640.002325120564211 in 430219,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_sats

The 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) = 0

The 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_sats

The 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:

  1. A new order receives one dedicated Bitcoin deposit address.
  2. No unpaid order reserves any claims.
  3. An underpaid order stays pending_payment.
  4. Once confirmed payments reach quote_total_sats, the worker attempts allocation.
  5. The allocator may assign a same-size set cheaper than the quoted requested set if the requested set conflicts.
  6. The allocated fair price enters the prize pool.
  7. 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_sats

Because 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 numbers

Candidate ranking is:

  1. Maximize allocated fair price.
  2. Maximize overlap with requested numbers.
  3. Minimize total numeric distance from requested numbers.
  4. 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)) >= 6

Proof:

  • 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

  1. A round is open over a block-height interval.
  2. Players create orders against the earliest open eligible round.
  3. Confirmed payments are attached to dedicated order addresses.
  4. Fully paid orders are allocated into the earliest open eligible round.
  5. Once the closing block is known, the worker records the closing block hash.
  6. If no allocated orders exist, the round settles with no winner and no prize.
  7. If allocated orders exist, the worker picks one winning order by allocated fair price.
  8. The worker samples one internal six-number claim inside the winning order by atomic claim fair price.
  9. The round stores winning_order_id, winning_claim_numbers, closing_block_hash, settled_at, and later payout_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 modulo

The 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:

TablePurpose
roundsRound schedule, closing block hash, winner, winning claim numbers, payout txid, settlement timestamp.
ticket_ordersOne Lucky order: requested hints, quote, payment totals, allocation, fees, and status.
ticket_order_numbersNormalized allocated numbers for overlap checks and public ledger queries.
deposit_addressesOne dedicated Bitcoin address per order.
paymentsConfirmed outputs to order deposit addresses.
mempool_paymentsUnconfirmed payment hints for UI status.
partner_sitesPartner fee rate, owner, deletion state, and API key.
partner_balancesDerived 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:

RoutePurpose
POST /orders/quoteReturn fair budget, explicit fee, total due, probability, and claim count for requested numbers.
POST /ordersCreate an order and dedicated deposit address.
GET /orders/:idReturn one public order.
GET /orders?partnerId&playerIdentifierReturn player order history.
GET /bootstrapReturn 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:

  1. Every allocated order has allocated_price_sats = fair_price_sats(allocated_numbers).
  2. Every allocated order has explicit_fee_sats = ceil(allocated_price_sats * partner_fee_rate).
  3. No pair of allocated orders in the same round has an intersection of six or more numbers.
  4. prize_pool_sats = sum(allocated_price_sats) for the round.
  5. The winning order is the deterministic weighted sample from the closing block hash and allocated order prices.
  6. The winning claim is a deterministic weighted sample of internal six-number claims inside the winning order.
  7. The winning claim is contained in the winning order's allocated numbers.
  8. 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