返回抽奖列表

公开可验证抽奖算法

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 必须一致。

抽奖算法 · partial-fisher-yates-btc-hash-v1 | YSign