HOOKEDIN-LOTTO-1

Provably fair

A draw you can only check by asking us is not a draw you can check. Everything Hookedin uses to pick the winning numbers is public, and the function that does the picking is one file you can run in a browser tab.

01

Authenticate the round

Confirm Luckotto’s commitment, delay function and seed with its own published protocol.

02

Authenticate our tickets

Hash Hookedin’s receipt manifest and match it against the hash committed inside the round.

03

Run one function

Feed both to hookedinDraw. It returns the six winning numbers and chooses its own branch.

1. Verify the two inputs

First verify the round result and its drawExtensionSeed with Luckotto’s provably fair protocol or its browser verifier. That establishes which partner won and which seed the round produced.

Hookedin’s partner identity is one constant. Everything below is meaningless if this address is not the one you check against:

HOOKEDIN_PAYOUT_ADDRESS = "bc1p2g7v73y62h2hksphng68px87pcm04h30rfzpdqmrc3776xe90q5sn286wm"

Download the Hookedin partner receipt manifest:

https://luckotto.com/api/rounds/<roundNumber>/partners/bc1p2g7v73y62h2hksphng68px87pcm04h30rfzpdqmrc3776xe90q5sn286wm/manifest

Hash the manifest’s exact response bytes with SHA-256 and compare the lowercase digest with Hookedin’s partnerCommitmentHash in the committed Luckotto round manifest. Confirm its partnerPayoutAddress is exactly the constant above. That manifest is the complete list of tickets that were in play, fixed before the seed existed.

2. What the function does

It first rejects anything malformed: mismatched rounds, invalid Hookedin metadata, duplicate combinations, and a manifest that somehow contains every possible combination. Sales stop at 1,947,791 tickets, so at least one of the 1,947,792 combinations is always unsold.

It decides whether Hookedin won by comparing the result’s complete winningPartnerPayoutAddress with the authenticated manifest’s complete partnerPayoutAddress. Nothing else can flip that branch.

To draw a uniform index it decodes the 64-character extension seed into 32 bytes and appends a counter as an unsigned 32-bit big-endian integer. Native Web Crypto SHA-256 hashes those exact 36 bytes, and the complete digest is read as one unsigned 256-bit big-endian integer. Rejection sampling removes modulo bias. There is no text encoding, protocol tag, purpose tag, or round-number input.

  • If Hookedin won, the index selects one ticket after sorting the complete lowercase UUIDs in ascending order. That ticket takes the full 20 BTC.
  • If Hookedin did not win, the index selects uniformly from the ordered combinations absent from the manifest, so no sold ticket can match the published result.

3. Run the exact JavaScript

This is the complete verifier Hookedin itself runs — not a reimplementation of it. It is also served directly as /hookedin-draw.js.

Example browser usage, after independently authenticating both responses:

const manifest = await fetch(
  "https://luckotto.com/api/rounds/<roundNumber>/partners/bc1p2g7v73y62h2hksphng68px87pcm04h30rfzpdqmrc3776xe90q5sn286wm/manifest"
).then(response => response.json());

const luckottoResult = await fetch(
  `https://luckotto.com/api/rounds/${manifest.roundNumber}`
).then(response => response.json());

const { hookedinDraw } = await import("/hookedin-draw.js");
const winningNumbers = await hookedinDraw(manifest, luckottoResult);

console.log(winningNumbers);
// manifest is the parsed, authenticated Hookedin partner receipt manifest.
// luckottoResult is the verified Luckotto result for the same round.
// Returns a promise for the six winning numbers in ascending order.
export async function hookedinDraw(manifest, luckottoResult) {
  const TOTAL_COMBINATIONS = 1_947_792;
  const UUID_V4 =
    /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;

  const normalize = (value) => {
    if (!Array.isArray(value) || value.length !== 6) {
      throw new Error("Each ticket must contain six numbers");
    }
    const numbers = [...value].sort((a, b) => a - b);
    if (
      numbers.some((number) => !Number.isInteger(number) || number < 1 || number > 36) ||
      new Set(numbers).size !== 6
    ) {
      throw new Error("Ticket numbers must be distinct integers from 1 through 36");
    }
    return numbers;
  };

  const choose = (n, k) => {
    k = Math.min(k, n - k);
    let result = 1;
    for (let index = 1; index <= k; index += 1) {
      result = (result * (n - k + index)) / index;
    }
    return result;
  };

  const rank = (numbers) => {
    let result = 0;
    let minimum = 1;
    for (let position = 0; position < 6; position += 1) {
      const remaining = 5 - position;
      for (let candidate = minimum; candidate < numbers[position]; candidate += 1) {
        result += choose(36 - candidate, remaining);
      }
      minimum = numbers[position] + 1;
    }
    return result;
  };

  const unrank = (combinationRank) => {
    const numbers = [];
    let minimum = 1;
    for (let position = 0; position < 6; position += 1) {
      const remaining = 5 - position;
      for (let candidate = minimum; candidate <= 36 - remaining; candidate += 1) {
        const blockSize = choose(36 - candidate, remaining);
        if (combinationRank < blockSize) {
          numbers.push(candidate);
          minimum = candidate + 1;
          break;
        }
        combinationRank -= blockSize;
      }
    }
    return numbers;
  };

  const sample = async (upperBound) => {
    const range = BigInt(upperBound);
    const size = 1n << 256n;
    const limit = size - (size % range);
    const message = new Uint8Array(36);
    for (let index = 0; index < 32; index += 1) {
      message[index] = Number.parseInt(
        luckottoResult.drawExtensionSeed.slice(index * 2, index * 2 + 2),
        16
      );
    }
    const counterBytes = new DataView(message.buffer);
    for (let counter = 0; counter <= 0xffff_ffff; counter += 1) {
      counterBytes.setUint32(32, counter, false);
      const digest = new Uint8Array(
        await globalThis.crypto.subtle.digest("SHA-256", message)
      );
      let value = 0n;
      for (const byte of digest) value = (value << 8n) | BigInt(byte);
      if (value < limit) return Number(value % range);
    }
    throw new Error("SHA-256 rejection sampler exhausted its counter");
  };

  if (
    !manifest ||
    !Number.isSafeInteger(manifest.roundNumber) ||
    manifest.roundNumber < 1 ||
    typeof manifest.partnerPayoutAddress !== "string" ||
    manifest.partnerPayoutAddress.length === 0 ||
    !Array.isArray(manifest.tickets)
  ) {
    throw new Error("Invalid Hookedin partner receipt manifest");
  }
  if (
    !luckottoResult ||
    luckottoResult.roundNumber !== manifest.roundNumber ||
    !/^[0-9a-f]{64}$/.test(luckottoResult.drawExtensionSeed) ||
    (
      luckottoResult.winningPartnerPayoutAddress !== null &&
      typeof luckottoResult.winningPartnerPayoutAddress !== "string"
    )
  ) {
    throw new Error("Invalid or mismatched Luckotto result");
  }

  const tickets = manifest.tickets
    .map((ticket) => {
      const metadata = ticket?.metadata?.hookedinLotto;
      if (
        !UUID_V4.test(ticket?.id) ||
        metadata?.version !== 1 ||
        metadata.ticketId !== ticket.id
      ) {
        throw new Error("Invalid Hookedin ticket metadata");
      }
      return { id: ticket.id, numbers: normalize(metadata.numbers) };
    })
    .sort((left, right) => left.id < right.id ? -1 : left.id > right.id ? 1 : 0);

  const sold = new Set(tickets.map((ticket) => ticket.numbers.join("-")));
  if (sold.size !== tickets.length) {
    throw new Error("Ticket number combinations must be unique within a round");
  }
  if (sold.size >= TOTAL_COMBINATIONS) {
    throw new Error("Each round must retain at least one unsold number combination");
  }

  const hookedinWon =
    luckottoResult.winningPartnerPayoutAddress === manifest.partnerPayoutAddress;
  if (hookedinWon) {
    if (tickets.length === 0) {
      throw new Error("Hookedin cannot win without purchased tickets");
    }
    return tickets[await sample(tickets.length)].numbers;
  }

  const soldRanks = tickets.map((ticket) => rank(ticket.numbers)).sort((a, b) => a - b);
  let winningRank = await sample(TOTAL_COMBINATIONS - soldRanks.length);
  for (const soldRank of soldRanks) {
    if (soldRank > winningRank) break;
    winningRank += 1;
  }
  return unrank(winningRank);
}

What this does and does not prove

It proves the winning numbers follow deterministically from data committed before the seed existed, so Hookedin cannot have chosen it after the fact, and cannot have quietly added or removed tickets.

It does not prove Hookedin is solvent, licensed, or able to pay. For what a ticket actually costs you, see the odds and house edge; for how payment works, see the rules.