全部文章

SealDraw

让任何人都能验证的 Solana 抽奖:先承诺、再冻结、交给未来的区块、最后公开

我们怎么做出一个主办方和服务器都没法挑中奖者的抽奖,而且任何人都能用一个零依赖的脚本把开奖重跑一遍。

7 分钟读完

大多数线上抽奖都要你相信某个人。主办方贴出三个钱包地址,说是随机抽的。你看不到过程,也没法知道报名名单有没有被改过,或者是不是重抽了好几次,直到抽出"对的"钱包为止。

我们想做一个不需要这种信任的抽奖。目标一句话就能说清楚:在报名截止之前,没有人(包括主办方和我们自己的服务器)能知道谁会中奖;截止之后,也没有人能改结果。而且任何人都应该能在自己的电脑上验证这一点。

这篇文章讲我们在 SealDraw(一个 Solana 上的抽奖服务)里是怎么做到的。下面四个步骤,单独拿出来都不新鲜。重要的是它们怎么组合在一起,以及那些决定"证明到底能不能被验证"的小细节。

第 1 步:报名开始前,先承诺一个秘密种子

建立抽奖时,服务器生成一个 32 字节的秘密 seed,只公开它的哈希:

seedCommit = SHA-256(seed)

种子在开奖前一直保密。因为承诺值从第一分钟就公开了,我们没办法等看到谁报名之后再偷偷换种子。如果换了,公开出来的种子就对不上当初的承诺值。

种子不存在数据库里,而是由服务器密钥和每场抽奖各自的随机 nonce 推导出来:

export function raffleSeed(seedSecret: string, seedNonce: string): Uint8Array {
  return hmac(sha256, utf8.encode(seedSecret), utf8.encode(`proofdraw:seed:${seedNonce}`));
}

所以就算有人拿到整个数据库,也看不到任何种子。(proofdraw: 这个标签是项目旧名字留下来的,后面会讲。)

同一时间,奖品、中奖人数、截止时间和报名条件也都锁定了。抽奖开始后,主办方就不能再改。

第 2 步:报名是签名,不是数据库里的一行

报名不用花钱,也不用发交易。报名者用钱包签一段固定的文字:

I enter SealDraw raffle #<id>

服务器用钱包公钥验证这个 Ed25519 签名,消息和签名都会公开。一份地址名单可以塞进假的报名;一份签名名单不行,验证程序会逐一检查每个签名。

一台 Seeker 只能报名一次

新建一个 Solana 钱包不用钱,所以"每个钱包限报一次"实际上等于"每个机器人限报一次"。我们提供的最强选项是"只限 Seeker"的抽奖:每台 Solana Seeker 手机都有一枚 Seeker Genesis Token,抽奖规定每枚 Token 只能报名一次。

报名时,服务器检查这个钱包的 Token-2022 账户,看其中有没有属于 Genesis Token 组的(也就是 mint 带有 tokenGroupMember 扩展)。找到的 mint 会跟报名记录一起保存,剩下的交给数据库:

UNIQUE (raffle_id, genesis_mint)

如果同一台 Seeker 换个钱包再报一次,写入就会失败,报名者会看到"This Seeker already entered with another wallet"(这台 Seeker 已经用另一个钱包报名过了)。

开放给所有钱包的抽奖,主办方必须设至少一个持有条件(最多五个):持有一定数量的 SOL、持有某个代币,或者持有某个系列的 NFT。正是这些条件让每多开一个钱包都要付出代价。

第 3 步:截止时重新检查、冻结名单、计算哈希

只在报名那一刻检查持有,有个明显的漏洞:用一个钱包报名,把代币转到下一个钱包,再报一次。所以抽奖截止时,冻结名单之前会把每一笔报名重新检查一遍:

async function recheckEntry(ctx, e, gates, checkGenesis) {
  if (checkGenesis) {
    const mint = await ctx.solana.genesisMint(e.wallet);
    if (!mint || mint !== e.genesis_mint) return "no longer holds the Seeker Genesis Token it entered with";
  }
  if (gates.length) {
    const v = await checkGates(ctx.solana, e.wallet, gates);
    if (!v.ok) return v.reason;
  }
  return null;
}

不再符合条件的报名会被移除,原因跟证明一起公开。没有任何报名会悄悄消失。

这个检查跑在 Cloudflare Workers 的定时任务里,而 Workers 限制了单次运行能发出的外部请求数。所以每次最多检查 300 笔,下一分钟从还没检查的那一笔接着做。一场 5,000 人的抽奖大约要跑 17 次才能截止。这没关系:名单晚几分钟冻结,不会改变任何结果。

全部报名都重新检查过之后,名单就冻结了:

  • 钱包按纯文本顺序排序,
  • 用 \n 连接,结尾不加换行,
  • entriesHash = SHA-256(list)。

这些规则看起来很啰嗦,但两个人拿到同样的报名名单,必须得出一模一样的字节,否则这个哈希就没有意义。

同一时间,服务器宣布一个还不存在的区块:

const drawSlot = (await ctx.solana.finalizedSlot()) + DRAW_SLOT_OFFSET; // 150

Solana 每个 slot 大约 400 毫秒,所以 150 个 slot 大约是一分钟之后。

第 4 步:让未来的区块决定,然后公开种子

开奖用的是 drawSlot 当时或之后产生的第一个区块(没有出块的 slot 会跳过),而且要等它 finalized(最终确认)。然后:

drawSeed = SHA-256(seed || entriesHash || blockhash)

一共 32 + 32 + 32 字节:种子、从十六进制解码的 entries hash,以及从 base58 解码的 blockhash。

为什么三样都要?

  • 种子在任何人报名之前就固定了,没有人能挑一个对某些报名者有利的种子。
  • entries hash 把结果绑定在那份冻结的名单上。
  • 名单冻结时,blockhash 还不存在。所以就连知道种子的我们,也没法在最后一刻塞进一笔报名并预测它的影响。

我们用 drawSeed 产生一串随机数。第 i 块是 SHA-256(drawSeed || i),i 是 4 字节的大端整数,每块读成八个 32 位无符号整数。为了得到一个小于 n 又没有偏差的数,落在不整除尾段的值会被丢掉:

nextInt(n: number): number {
  const limit = 2 ** 32 - ((2 ** 32) % n);
  let v = this.next32();
  while (v >= limit) v = this.next32();
  return v % n;
}

中奖者来自对排序后名单做的部分 Fisher-Yates 洗牌。前 k 个位置就是中奖者,按名次排列:

for (let i = 0; i < k; i++) {
  const j = i + rng.nextInt(list.length - i);
  [list[i], list[j]] = [list[j], list[i]];
}
return list.slice(0, k);

公布之前,服务器会再检查一次:用来抽奖的名单是否仍然对得上公开的 entriesHash,种子是否仍然对得上承诺值。只要有一项不符,就拒绝公布。否则,种子、区块和中奖者会在同一个数据库交易里一起公布。

另外还有期限。结果必须在截止后 31 分钟内公布。如果没有,这场抽奖会被公开标记为 "late"(延迟)。它仍然会尽快用同一个区块开奖,但这个标记会一直留着。如果我们的定时任务卡住了,大家都看得到,不会被藏起来。

不用相信我们也能验证

每场抽奖都会公开一份 JSON 格式的证明。抽奖页面上有个按钮,可以在你的浏览器里重跑一次开奖。但跑在我们网页上的检查,终究还是我们的代码。所以同一套逻辑也做成了一个独立脚本:

curl -O https://sealdraw.2cstudio.app/verify.mjs
node verify.mjs https://sealdraw.2cstudio.app/raffles/12/proof --rpc https://api.mainnet-beta.solana.com

它需要 Node.js 20 或更新版本,不依赖任何套件。SHA-256 和 Ed25519 用的是 Web Crypto,再加一个很短的 base58 解码器。core start 和 core end 标记之间的代码,跟浏览器里跑的是同一份。它按顺序检查:

  1. 种子的哈希等于承诺值,
  2. 每一笔报名都带有有效签名,
  3. 名单已排序、没有重复,而且对得上 entriesHash,
  4. 加上 --rpc 时,决定结果的区块确实是 drawSlot 当时或之后的第一个区块,而且 Solana 回报的 blockhash 相同,
  5. 重跑洗牌得到同样的中奖者。

"跳过"不等于"通过"

有一个我们很在意的细节:每一步的结果有三种,而不是两种:ok、FAIL 或 skip。

有些检查不是每次都能做。旧版浏览器没法通过 Web Crypto 验证 Ed25519 签名;如果你不加 --rpc,脚本就没法向 Solana 查询区块。这些情况下,那一步会标为跳过,并写明原因:

skip  this browser cannot check Ed25519 signatures; use verify.mjs or a newer browser
skip  blockhash of block … not checked against Solana

把"没法检查"当成"没问题",显示一个绿色勾勾,是很容易的事。但那会让证明看起来比实际更有力。

证明必须永远有效

两个关于"让旧证明一直能被验证"的小故事。

改名。 这个项目在 2026 年 10 月 17 日之前叫 ProofDraw。第 1 场抽奖的报名,签的是 I enter ProofDraw raffle #1。如果我们把报名文字全部改掉,那场抽奖公开的签名就再也验证不过了。所以消息内容按抽奖编号决定,并用测试锁死:

export const LEGACY_NAME_LAST_ID = 1;
export const entryMessage = (raffleId: number) =>
  `I enter ${raffleId <= LEGACY_NAME_LAST_ID ? "ProofDraw" : "SealDraw"} raffle #${raffleId}`;

内部的 proofdraw:seed: 标签留下来也是同样的原因。改了它,第 1 场抽奖的种子就会变。

版本号。 后来加入的持有者快照抽奖,会按持有量自动给每个持有者分配抽奖券。它们需要不同的名单格式(wallet:tickets)和加权抽选,所以证明里带有 version: 2。验证程序从证明里读版本号,版本 1 的证明永远按版本 1 的规则检查。

这套做法管得到什么、管不到什么

SealDraw 从不保管奖品。奖品由主办方发送,并把交易贴在结果页上,结果页会显示他们有没有发。数学能证明谁中了奖,但证明不了有人付了钱。

试试看

如果你想用这种方式办一场抽奖,或者只是想拿脚本研究一份证明,SealDraw 的网址是 https://sealdraw.2cstudio.app。报名永远免费。完整规则写在网站的公平性页面,verify.mjs 也可以在网站上下载。

作者:2C Studio,一间小工作室。

试试看

SealDraw

sealdraw.2cstudio.app

更多文章