公开可验证抽奖算法
partial-fisher-yates-btc-hash-v1
这是本站抽奖系统公开使用的核心算法说明与 Node.js 参考实现。它不依赖服务器私有随机数,也不依赖管理员手工选择结果;只要输入相同,后端权威开奖、浏览器复算和第三方独立实现都会得到完全相同的中奖序号。
1. 冻结名单
报名关闭后,系统按参与序号升序固化快照。快照只公开序号、脱敏展示名和用户哈希;对这份规范 JSON 计算 SHA-256 得到 snapshot_hash。开奖后任何增删、改序或改名都会导致哈希不一致。
2. 锁定随机源
Seed 来源支持“BTC 未来区块 Hash”和“BTC 最新区块 Hash”。推荐使用未来区块模式:活动发布时提前指定尚未产生的高度,区块产生前没人能预知其哈希。最新区块模式会在开奖时读取当前 tip height,并把实际高度和哈希写回公示区,便于复算。
核心输入
btc_block_hash
由 Seed 来源决定的 BTC 区块哈希。推荐模式提前锁定未来区块;最新区块模式在开奖时读取当前 tip height。
participant_count
冻结快照中的有效参与人数,序号默认是 1..N 的连续整数。
gift_count
启用奖项按后台排序展开后的奖品槽位总数。
snapshot_hash
公开快照规范 JSON 的 SHA-256,用来证明名单没有被事后修改。
核心逻辑摘要
算法使用 SHA-256 计数器模式生成确定性随机字节流,并通过拒绝采样生成均匀整数。抽取阶段使用 partial Fisher-Yates:只洗前 winnerCount 个位置,适合大规模名单快速复算。
winnerCount = min(participantCount, giftCount)
seed = hexToBytes(normalizeBtcBlockHash(btcBlockHash))
rng(counter) = sha256(seed || "|" || "winner-serials" || "|" || u64be(counter))
for i in 1..winnerCount:
j = randomIntInclusive(i, participantCount) // rejection sampling, no modulo bias
valueAtI = swapped[i] ?? i
valueAtJ = swapped[j] ?? j
swapped[i] = valueAtJ
swapped[j] = valueAtI
drawOrderSerials.push(valueAtJ)
result_hash = sha256(JSON.stringify(drawOrderSerials))完整 JavaScript 参考实现
可直接复制到本地 Node.js 环境,用公开的 btc_block_hash、参与人数、奖品数和快照数据复算。
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按 serial_no 升序下载公开快照,重新序列化并计算 snapshot_hash。
2校验 btc_block_hash 是否与公示 btc_block_height 的链上区块一致。
3用区块哈希、参与人数和奖品总数派生确定性随机流。
4执行 partial Fisher-Yates,得到 drawOrderSerials。
5对 drawOrderSerials 的紧凑 JSON 计算 result_hash,并与公示值比对。
活动详情页的「验证本次开奖」按钮会在浏览器内执行上述步骤。你也可以按照这份摘要用 Go、TypeScript、Python 或任何语言自行实现,只要输入一致,输出的 drawOrderSerials 和 result_hash 必须一致。