Алгоритм розыгрыша · открытая проверка
Результат ежедневного розыгрыша Hvoy можно воспроизвести по открытому seed, снимку допущенных номеров и количеству призов.
Как проходит розыгрыш
Каждая купленная заявка сразу записывается и получает глобально уникальный последовательный номер, например #00827.
Система фиксирует список допущенных заявок, создаёт entries.json и публикует entries_hash, поэтому последующие изменения можно обнаружить.
В качестве seed используется последний BTC tip hash около времени запуска розыгрыша. Он открыт и не контролируется Hvoy.
BTC hash используется как seed для частичной перестановки Фишера — Йетса по открытому списку кандидатов без повторного выбора.
Победители получают 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. После публикации entries_hash и entries.json сначала проверьте целостность снимка.
- 2. Используйте BTC hash как seed, прочитайте номера кандидатов из entries.json и подтвердите число призов.
- 3. Выполните Partial Fisher-Yates Shuffle над кандидатами и получите выигрышные номера.
- 4. Сравните результат с опубликованным списком победителей.
Частые вопросы
Почему seed основан на хеше блока BTC?+
После появления BTC tip hash становится общедоступным, а Hvoy не может заранее выбрать его значение.
Почему seed не публикуется до розыгрыша?+
Он становится известен только при выполнении задачи, поэтому нельзя покупать номера после просмотра итогового seed.
Раскрывает ли entries.json личные данные?+
Снимок содержит только номера и замаскированные обозначения пользователей, без адресов почты и телефонов.
Может ли алгоритм измениться?+
Текущая открытая версия называется Partial Fisher-Yates Shuffle. Новые версии должны объявляться, а исторические результаты — оставаться проверяемыми.