Draw Algorithm · Public Verification
Hvoy's daily draw is public. Anyone can reproduce the result from the public seed, eligible-entry snapshot, and entry count.
Draw flow
Each purchased entry is written immediately and receives a globally unique serial number such as #00827.
The system freezes the eligible entry list, creates entries.json, and publishes entries_hash so later changes are detectable.
The seed uses the latest BTC tip hash near draw execution time. It is public and not controllable by Hvoy.
The BTC hash seed drives Partial Fisher-Yates Shuffle over the published candidate list to select winner serial numbers without replacement.
Winner users receive API Keys and the public winner list marks delivery status.
Reference implementation
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");
}How to verify this round
- 1. After entries_hash and entries.json are public, verify the snapshot first.
- 2. Use the BTC hash as the seed, read candidate serial numbers from entries.json, and confirm the prize count.
- 3. Run Partial Fisher-Yates Shuffle over the candidate numbers to reproduce the winners.
- 4. Compare the reproduced numbers with the public winner list.
FAQ
Why use a BTC block hash as the seed?+
The BTC tip hash is public after it appears and cannot be chosen by Hvoy in advance.
Why is the seed hidden before the draw?+
The seed is only known when the draw task runs, so nobody can buy entries after seeing the final seed.
Does entries.json expose private data?+
The snapshot only contains serial numbers and masked user labels, not emails or phone numbers.
Can the algorithm change?+
The current public name is Partial Fisher-Yates Shuffle. Any future version should be announced and historical results remain verifiable.