Technical reference

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

GET /api/luckotto/bootstrapReturns partner metadata, Luckotto constants, selected round, tile stats, and player tickets.
GET /api/luckotto/roundsReturns the public round ledger with partner context. Query parameters: partner payout address required as partner, limit optional.
GET /api/luckotto/rounds/:roundNumber/proofReturns the canonical machine-readable evidence packet for one round. No query parameters.
POST /api/luckotto/ticketsReserves one selected six-tile ticket and returns its deposit address. Body uses minRoundNumber when the caller supplies a round hint.
GET /api/luckotto/rounds/:roundNumber/tickets/:depositAddressIdReturns one finalized public Luckotto ticket by round number and deposit address ID.
GET /api/luckotto/ticketsReturns public player ticket history for a partner/player pair.
POST /api/luckotto/tickets/authenticatedTrusted server-side address creation using the partner API key. Body uses minRoundNumber when the caller supplies a round hint.

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

  1. Website user creates or rotates a partner API key.
  2. The embed calls /api/luckotto/bootstrap to load public display data.
  3. Iframe reserves one selected six-tile ticket address for the current round.
  4. On-time confirmations set the selected ticket's draw weight; late confirmations credit the next available round.
  5. The scanner records confirmed payments with the credited round number.
  6. 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.
  7. The monolith exposes public round, ticket, allocation, weight, and payout data.

Settlement rules

Ticket allocationReservation creates one selected six-tile deposit address request. Confirmed payments derive funded tickets by deposit address and credited round. Late confirmations reuse that request address in the next available round. Abandoned reservations carry no draw weight. Multiple tickets may share the same tile set.
Price and weightThere is no app minimum ticket price; any positive amount funds a ticket. The price is everything the player paid. The draw weight — the ticket's fair value entering the draw — is the price minus the partner's house-edge fee, fixed when the ticket was sold.
Draw commitmentAfter the commit-delay buffer, settlement hashes the secret-ordered round CSV, stores those exact bytes on rounds.round_csv, spends commitment_descriptor/roundNumber, commits the raw CSV hash at vout 0, and rolls funds to commitment_descriptor/(roundNumber + 1) at vout 1. The draw seed is the draw_block_hash; the draw runs over the stored CSV (with the DRAW_HASHES_PER_ELIMINATION protocol constant) after the draw block exists and is re-derived if a reorg changes it, becoming final once the draw block is buried the payout-confirmation depth.
Draw resultSettlement eliminates losing tiles by advancing one sequential hash chain, one draw:chain segment per elimination. Each eliminated tile choice is weighted by the draw weight behind the candidate tickets that would survive that elimination.
Winner ruleEliminations stop when one tile set remains. If multiple tickets share it, the next draw-chain sample selects one ticket UUID by weight. That ticket's sorted tile set is recorded as the round draw tiles.

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

usersWebsite login accounts for partner owners and admins.
partner_sitesPartner tenant settings, house edge, verification status, payout metadata, and server-side API key.
partner_payout_addressesGlobally owned partner payout addresses keyed by the public Bitcoin address. The latest updated_at row is the public partner handle.
deposit_addressesHash-tweaked Bitcoin deposit addresses scoped to one ticket request.
paymentsConfirmed Bitcoin outputs to deposit addresses, including the round each payment credits.
funded_ticketsPayment-derived view grouped by deposit address and credited round.
mempool_paymentsUnconfirmed deposit payments with dropped-mempool timestamps.
roundsWeekly round schedule, round CSV commitment, commitment block, draw block, final draw tiles, winning ticket, and payout status.

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.

Validation rules

PartnerPartner payout address used in public dashboard routes and iframe URLs. Internal admin mutations still use the partner site's UUIDv7.
API keyGenerated, displayed, stored, and rotated by the website for trusted server-side API calls only. It must not be exposed in iframe URLs, browser code, or player-visible copy.
playerUnamePublic display name, normalized and capped at 120 characters. Stored on the deposit address request.
playerIdentifierPer-player secret, normalized and capped at 120 characters. Partners must generate it unpredictably (for example, HMAC of their user ID with a partner secret). A player may know their own; Luckotto never serves one player's identifier to anyone else, and public ticket data is keyed to playerUname only.
houseEdgePartner-owned fraction from 0 through 0.35: the share of each payment kept as partner profit instead of draw weight (fees round to the nearest satoshi). Snapshotted onto the deposit address when a ticket request is created; zero is allowed, negative is impossible. UI shows it as a percentage with two decimals.
Ticket IDRound-local UUIDv7 ticket ID equal to the deposit address row ID. Possession of the ID shows public ticket status (player display name, tiles, weight) but never the player identifier, deposit address, or hash-tweak payload.
TilesAllocated tickets always contain 6 selected tiles from 1 through 36.
TicketA ticket's price is the confirmed sats paid to it; its draw weight is that price minus the partner fee. The UI shows price, the draw and committed CSV use weight.