当前抽奖状态
当前抽奖状态-
按本轮配置时间执行
抽奖算法 · 公开验证
Hvoy 每日抽奖完全公开。任何人都可以用公开 Seed、候选快照和参与份额独立复现开奖结果。
抽奖算法
Partial Fisher-Yates Shuffle
v1.0
Seed 来源
BTC 最新区块 Hash
按本轮配置时间执行
无放回抽样
无放回抽样
同编号不会重复中奖
开奖流程
1
报名阶段 · 18:00 – 12:00
用户购买的每一次抽奖都会立即写入当日报名表,并分配一个全局唯一、连续递增的编号。
2
封盘 · 12:00
系统冻结有效候选报名表,生成 entries.json 快照,并计算 entries_hash,后续任何对候选表的篡改都能被检测出来。
3
等待 Seed · 12:00 – 14:00
Seed 使用开奖任务执行时的最新 BTC tip hash。该值公开可查,Hvoy 无法提前操控。
4
开奖 · 14:00
直接用 BTC Hash 作为 Seed,对公开候选编号执行 Partial Fisher-Yates Shuffle,按无放回方式抽取中奖编号。
5
发放 · 14:00 起
中奖编号对应用户会收到 API Key,并在中奖名单中标记发放状态。
参考实现
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. 与公开中奖名单逐一对比。
常见问题
为什么用 BTC 区块 Hash 当 Seed?+
BTC tip hash 出现后全网公开可查,开奖前 Hvoy 不能提前指定这个值。
为什么开奖前不公开 Seed?+
Seed 使用开奖任务执行时的最新 BTC tip hash,不能让任何人在看到最终 Seed 后再报名。
entries.json 会泄露隐私吗?+
报名快照只包含编号和脱敏用户标识,不包含手机号、邮箱等隐私字段。
算法会变吗?+
当前公开算法名是 Partial Fisher-Yates Shuffle;如未来升级,应保留历史版本可验证。