当前抽奖状态
-
按本轮配置时间执行

抽奖算法 · 公开验证

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. 1. entries_hash 与 entries.json 快照公开后,先校验快照。
  2. 2. 使用 BTC Hash 作为 Seed,并从 entries.json 读取候选编号、确认奖品数量。
  3. 3. 对候选编号运行 Partial Fisher-Yates Shuffle,得到中奖编号。
  4. 4. 与公开中奖名单逐一对比。

常见问题

为什么用 BTC 区块 Hash 当 Seed?+

BTC tip hash 出现后全网公开可查,开奖前 Hvoy 不能提前指定这个值。

为什么开奖前不公开 Seed?+

Seed 使用开奖任务执行时的最新 BTC tip hash,不能让任何人在看到最终 Seed 后再报名。

entries.json 会泄露隐私吗?+

报名快照只包含编号和脱敏用户标识,不包含手机号、邮箱等隐私字段。

算法会变吗?+

当前公开算法名是 Partial Fisher-Yates Shuffle;如未来升级,应保留历史版本可验证。