REST API and database contract
Partner-facing access is a small JSON REST API inside the same Next app as first-party admin pages.
Iframe contract
<iframe title="Partner Lotto" src="https://luckotto.example/embed?partner=tb1pyqn8k6d9lyjen5akpz7v42y37vc42cp6tzepvvvr8h63ulecu27suulrjw&playerUname=DisplayName&playerIdentifier=UNGUESSABLE-PER-PLAYER-SECRET" style="width:100%;max-width:560px;height:760px;border:0;" loading="lazy" ></iframe>
The iframe calls public /api/luckotto endpoints and must never receive an API key or server-side partner settings. The playerIdentifieris that player's own secret: generate it unpredictably per player and never reuse another player's value in a URL you hand out.
API contracts
Use the proof endpoint for independent round verification. It is the canonical JSON summary of the public evidence; the CSV URL still points to the exact bytes that must be hashed locally.
GET /api/luckotto/rounds/<roundNumber>/proof
{
"specVersion": "LUCKOTTO-1",
"network": "testnet4" | "signet" | "mainnet",
"proofUrl": string,
"roundNumber": number,
"status": "open" | "locked" | "revealed" | "resolved" | "paid_out",
"csvUrl": string | null,
"csvHash": string | null,
"csvByteLength": number | null,
"ticketCount": number | null,
"commitmentAddress": string,
"commitmentPath": string,
"commitmentTxid": string | null,
"commitmentBlockHeight": number | null,
"commitmentBlockHash": string | null,
"commitmentOutputIndex": 0,
"expectedCommitmentOutputScriptHex": string | null,
"drawBlockHeight": number | null,
"drawBlockHash": string | null,
"drawHashesPerElimination": number,
"drawTiles": number[] | null,
"winningTicketId": string | null,
"payoutTxid": string | null,
"parameters": {
"drawDelayBlocks": number,
"drawHashesPerElimination": number,
"minPayoutConfirmations": number
},
"csv": { "byteLength": number | null, "hash": string | null, "rowCount": number | null, "url": string | null },
"commitment": {
"descriptor": string,
"path": string,
"address": string,
"nextPath": string,
"nextAddress": string,
"txid": string | null,
"blockHeight": number | null,
"blockHash": string | null,
"outputIndex": 0,
"expectedOutputScriptHex": string | null
},
"draw": {
"delayBlocks": number,
"hashesPerElimination": number,
"blockHeight": number | null,
"blockHash": string | null,
"tiles": number[] | null,
"winningTicketId": string | null
},
"payout": { "txid": string | null }
}GET /api/luckotto/rounds?partner=<payout-address>&limit=<1-100>
{
"btcToUsd": number,
"partner": {
"id": string,
"displayName": string,
"verifiedAt": string | null,
"payoutAddress": string,
"houseEdge": number
},
"rounds": [
{
"roundNumber": number,
"opensAt": string,
"closesAt": string,
"tileCount": number,
"tilesPerTicket": number,
"soldTicketCount": string,
"prizePoolSats": string,
"roundCsvHash": string | null,
"commitmentTxid": string | null,
"commitmentBlockHeight": string | null,
"commitmentBlockHash": string | null,
"drawBlockHeight": string | null,
"drawBlockHash": string | null,
"drawTiles": number[] | null,
"status": "open" | "locked" | "revealed" | "resolved" | "paid_out",
"winningTicketId": string | null,
"payoutTxid": string | null,
"resolvedAt": string | null
}
]
}Lifecycle
- Website user creates or rotates a partner API key.
- The embed calls /api/luckotto/bootstrap to load public display data.
- Iframe reserves one selected six-tile ticket address for the current round.
- On-time confirmations set the selected ticket's draw weight; late confirmations credit the next available round.
- The scanner records confirmed payments with the credited round number.
- Round settlement freezes the round after a finality buffer, stores the secret-ordered CSV on the round and commits its hash on-chain, then as soon as the draw block exists reveals the CSV, runs the weighted tile-elimination draw over it, and records the surviving winning ticket. Until the draw block is buried the payout-confirmation depth, a reorg can re-derive the result from the same committed CSV.
- The monolith exposes public round, ticket, allocation, weight, and payout data.
Settlement rules
Draw math
The draw is funding-weighted: a ticket's chance of winning is proportional to its draw weight in the committed CSV, and the draw block hash drives the tile reveals that select the winning ticket.
Settlement: 1. parse the committed final CSV order 2. candidates = tickets with positive weight 3. advance the seed's draw:chain one slow segment per elimination to sample weighted tile eliminations 4. after each eliminated tile, remove tickets containing that tile 5. stop when one tile set remains 6. if multiple tickets share it, weighted-sample one ticket in CSV order
The draw calculator includes copyable reference JavaScript for replaying this algorithm from the draw block hash and final CSV order.
Database tables
Schema outline
CREATE TABLE partner_sites (
id uuid PRIMARY KEY,
owner_user_id uuid NOT NULL REFERENCES users(id),
house_edge double precision NOT NULL DEFAULT 0,
api_key_hash text NOT NULL UNIQUE
);
CREATE TABLE partner_payout_addresses (
payout_address text PRIMARY KEY,
partner_site_id uuid NOT NULL REFERENCES partner_sites(id),
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
updated_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
CREATE TABLE deposit_addresses (
id uuid PRIMARY KEY,
partner_payout_address text NOT NULL REFERENCES partner_payout_addresses(payout_address),
min_round_number integer NOT NULL REFERENCES rounds(round_number),
tiles integer[] NOT NULL,
player_identifier text NOT NULL,
player_uname text,
deposit_address text NOT NULL UNIQUE,
deposit_hash_tweak_payload text NOT NULL,
deposit_hash_tweak_path text NOT NULL
);
CREATE TABLE payments (
id uuid PRIMARY KEY,
deposit_address_id uuid NOT NULL REFERENCES deposit_addresses(id),
credited_round_number integer NOT NULL REFERENCES rounds(round_number),
amount_sats bigint NOT NULL,
block_height bigint NOT NULL,
txid text NOT NULL,
vout integer NOT NULL,
UNIQUE (txid, vout)
);
CREATE VIEW funded_tickets AS
SELECT
payments.deposit_address_id,
payments.credited_round_number AS round_number,
deposit_addresses.tiles,
SUM(payments.amount_sats) AS paid_sats
FROM payments
JOIN deposit_addresses ON deposit_addresses.id = payments.deposit_address_id
GROUP BY payments.deposit_address_id, payments.credited_round_number, deposit_addresses.tiles;
CREATE TABLE rounds (
round_number integer PRIMARY KEY,
closes_at timestamptz NOT NULL,
locked_block_height bigint, -- tip at close; starts the commit-delay buffer
round_csv_hash text,
round_csv text, -- exact committed CSV bytes
commitment_txid text,
commitment_block_height bigint,
commitment_block_hash text,
draw_block_height bigint,
draw_block_hash text,
draw_tiles integer[],
winning_deposit_address_id uuid,
settled_at timestamptz
);
CREATE VIEW round_statuses AS
SELECT
round_number,
CASE
WHEN payout_txid IS NOT NULL THEN 'paid_out'
WHEN settled_at IS NOT NULL THEN 'resolved'
WHEN draw_block_hash IS NOT NULL THEN 'revealed'
WHEN round_csv_hash IS NOT NULL THEN 'locked'
WHEN locked_block_height IS NOT NULL THEN 'locked'
WHEN closes_at <= now() THEN 'locked'
ELSE 'open'
END AS status
FROM rounds;
-- /api/luckotto exposes Luckotto ticket routes over direct queries.
-- POST /api/luckotto/tickets returns a valid min-round-scoped deposit address.