Названия призов и открытые имена пользователей показаны на языке настройки или отправки.
Состояние текущего розыгрыша
-
Время определяется настройками этого раунда

Алгоритм розыгрыша · открытая проверка

Результат ежедневного розыгрыша Hvoy можно воспроизвести по открытому seed, снимку допущенных номеров и количеству призов.

Алгоритм
Частичная перестановка Фишера — Йетса
v1.0
Источник seed
Хеш последнего блока BTC
Время определяется настройками этого раунда
Выбор без повторений
Выбор без повторений
Один номер не может выиграть дважды

Как проходит розыгрыш

1
Приём заявок · 18:00 – 12:00

Каждая купленная заявка сразу записывается и получает глобально уникальный последовательный номер, например #00827.

2
Закрытие · 12:00

Система фиксирует список допущенных заявок, создаёт entries.json и публикует entries_hash, поэтому последующие изменения можно обнаружить.

3
Ожидание Seed · 12:00 – 14:00

В качестве seed используется последний BTC tip hash около времени запуска розыгрыша. Он открыт и не контролируется Hvoy.

4
Розыгрыш · 14:00

BTC hash используется как seed для частичной перестановки Фишера — Йетса по открытому списку кандидатов без повторного выбора.

5
Выдача · после 14:00

Победители получают API-ключи, а в открытом списке отмечается состояние выдачи.

Эталонная реализация

import crypto from "node:crypto";

export const LOTTERY_ALGORITHM_VERSION = "partial-fisher-yates-btc-hash-v1";

export function normalizeBtcBlockHash(hash) {
  if (typeof hash !== "string") {
    throw new TypeError("btcBlockHash must be a string");
  }
  let value = hash.trim().toLowerCase();
  if (value.startsWith("0x")) value = value.slice(2);
  if (!/^[0-9a-f]{64}$/.test(value)) {
    throw new Error("btcBlockHash must be 64 hex characters");
  }
  return value;
}

function assertNonNegativeSafeInteger(name, value) {
  if (!Number.isSafeInteger(value) || value < 0) {
    throw new Error(`${name} must be a non-negative safe integer`);
  }
}

function sha256(data) {
  return crypto.createHash("sha256").update(data).digest();
}

export function deriveLotterySeed({ btcBlockHash, participantCount, giftCount }) {
  const hash = normalizeBtcBlockHash(btcBlockHash);
  assertNonNegativeSafeInteger("participantCount", participantCount);
  assertNonNegativeSafeInteger("giftCount", giftCount);

  const seedMaterial = [
    LOTTERY_ALGORITHM_VERSION,
    `btc_block_hash=${hash}`,
    `participant_count=${participantCount}`,
    `gift_count=${giftCount}`,
  ].join("\n");
  return {
    seed: Buffer.from(hash, "hex"),
    seedHex: hash,
    seedMaterial,
  };
}

function bitLength(input) {
  if (input < 0n) throw new Error("bitLength input must be non-negative");
  let bits = 0;
  let value = input;
  while (value > 0n) {
    bits += 1;
    value >>= 1n;
  }
  return bits;
}

function u64be(input) {
  if (input < 0n || input > 0xffffffffffffffffn) {
    throw new Error("counter overflow");
  }
  const buffer = Buffer.alloc(8);
  buffer.writeBigUInt64BE(input);
  return buffer;
}

class DeterministicRng {
  constructor(seed, domain) {
    if (!Buffer.isBuffer(seed) || seed.length !== 32) {
      throw new Error("seed must be a 32-byte Buffer");
    }
    this.seed = seed;
    this.domain = Buffer.from(domain, "utf8");
    this.counter = 0n;
    this.buffer = Buffer.alloc(0);
  }

  randomBytes(n) {
    if (!Number.isSafeInteger(n) || n < 0) {
      throw new Error("n must be a non-negative safe integer");
    }
    while (this.buffer.length < n) {
      const msg = Buffer.concat([
        this.seed,
        Buffer.from("|", "utf8"),
        this.domain,
        Buffer.from("|", "utf8"),
        u64be(this.counter),
      ]);
      const chunk = sha256(msg);
      this.buffer = Buffer.concat([this.buffer, chunk]);
      this.counter += 1n;
    }
    const out = this.buffer.subarray(0, n);
    this.buffer = this.buffer.subarray(n);
    return out;
  }

  randomIntInclusive(low, high) {
    if (!Number.isSafeInteger(low) || !Number.isSafeInteger(high)) {
      throw new Error("low/high must be safe integers");
    }
    if (low > high) throw new Error("low must be <= high");

    const span = BigInt(high - low + 1);
    const bits = bitLength(span - 1n);
    const byteLen = Math.max(1, Math.ceil(bits / 8));
    const maxValue = 1n << BigInt(byteLen * 8);
    const limit = maxValue - (maxValue % span);

    while (true) {
      const bytes = this.randomBytes(byteLen);
      const x = BigInt(`0x${bytes.toString("hex")}`);
      if (x < limit) {
        return low + Number(x % span);
      }
    }
  }
}

export function drawLottery(input) {
  const candidateSerials = Array.isArray(input.candidateSerials)
    ? input.candidateSerials.map((item) => Number(item)).filter((item) => Number.isSafeInteger(item) && item > 0)
    : null;
  const participantCount = candidateSerials ? candidateSerials.length : Number(input.participantCount);
  const giftCount = Number(input.giftCount);
  assertNonNegativeSafeInteger("participantCount", participantCount);
  assertNonNegativeSafeInteger("giftCount", giftCount);
  if (candidateSerials) {
    const unique = new Set(candidateSerials);
    if (unique.size !== candidateSerials.length) {
      throw new Error("candidateSerials must be unique positive safe integers");
    }
  }
  const serialAtPosition = (position) => candidateSerials ? candidateSerials[position - 1] : position;

  const { seed, seedHex, seedMaterial } = deriveLotterySeed({
    btcBlockHash: input.btcBlockHash,
    participantCount,
    giftCount,
  });
  const winnerCount = Math.min(participantCount, giftCount);
  if (winnerCount === 0) {
    return {
      algorithm: LOTTERY_ALGORITHM_VERSION,
      participantCount,
      giftCount,
      winnerCount,
      seedHex,
      seedMaterial,
      drawOrderSerials: [],
      winnerSerialsSorted: [],
    };
  }
  if (giftCount >= participantCount) {
    const all = Array.from({ length: participantCount }, (_, index) => serialAtPosition(index + 1));
    return {
      algorithm: LOTTERY_ALGORITHM_VERSION,
      participantCount,
      giftCount,
      winnerCount,
      seedHex,
      seedMaterial,
      drawOrderSerials: all,
      winnerSerialsSorted: [...all].sort((a, b) => a - b),
    };
  }

  const rng = new DeterministicRng(seed, "winner-serials");
  const swapped = new Map();
  const drawOrderSerials = [];
  for (let i = 1; i <= winnerCount; i += 1) {
    const j = rng.randomIntInclusive(i, participantCount);
    const valueAtI = swapped.has(i) ? swapped.get(i) : i;
    const valueAtJ = swapped.has(j) ? swapped.get(j) : j;
    swapped.set(i, valueAtJ);
    swapped.set(j, valueAtI);
    drawOrderSerials.push(serialAtPosition(valueAtJ));
  }

  return {
    algorithm: LOTTERY_ALGORITHM_VERSION,
    participantCount,
    giftCount,
    winnerCount,
    seedHex,
    seedMaterial,
    drawOrderSerials,
    winnerSerialsSorted: [...drawOrderSerials].sort((a, b) => a - b),
  };
}

export function serializeLotteryEntries(entries) {
  const normalized = (entries || []).map((entry) => ({
    serial_no: Number(entry.serialNo ?? entry.serial_no),
    user_label: String(entry.userLabel ?? entry.user_label ?? entry.usernameLabel ?? entry.username_label ?? ""),
  }));
  normalized.sort((a, b) => a.serial_no - b.serial_no);
  return JSON.stringify(normalized);
}

export function hashLotteryEntries(entries) {
  return crypto.createHash("sha256").update(serializeLotteryEntries(entries)).digest("hex");
}

Как проверить этот раунд

  1. 1. После публикации entries_hash и entries.json сначала проверьте целостность снимка.
  2. 2. Используйте BTC hash как seed, прочитайте номера кандидатов из entries.json и подтвердите число призов.
  3. 3. Выполните Partial Fisher-Yates Shuffle над кандидатами и получите выигрышные номера.
  4. 4. Сравните результат с опубликованным списком победителей.

Частые вопросы

Почему seed основан на хеше блока BTC?+

После появления BTC tip hash становится общедоступным, а Hvoy не может заранее выбрать его значение.

Почему seed не публикуется до розыгрыша?+

Он становится известен только при выполнении задачи, поэтому нельзя покупать номера после просмотра итогового seed.

Раскрывает ли entries.json личные данные?+

Снимок содержит только номера и замаскированные обозначения пользователей, без адресов почты и телефонов.

Может ли алгоритм измениться?+

Текущая открытая версия называется Partial Fisher-Yates Shuffle. Новые версии должны объявляться, а исторические результаты — оставаться проверяемыми.