如何在Solana上使用Switchboard VRF生成链上随机数

本文详细介绍了如何在Solana上使用Switchboard Randomness On-Demand生成可验证的链上随机数。

概述

Solana 验证者本质上是确定性的,这意味着每个验证者在处理交易时必须得到相同的结果。这使得在程序内部无法进行传统的随机数生成。Switchboard 按需随机性 通过可验证随机函数 (VRF) 解决了这个问题,该函数能生成任何人都可以验证的防篡改随机值。

本指南将引导 Solana 开发者构建一个完整的 Anchor 程序,该程序向 Switchboard 请求随机数,并将其作为 1 到 100 之间的值存储在链上,同时编写一个 TypeScript 客户端来驱动完整的提交-揭示流程,该流程针对 Solana Devnet 运行。

摘要

  • 构建一个包含三个指令(initializerequest_rollsettle_roll)的 Solana Anchor 程序,使用 Switchboard 按需随机性生成可验证的随机数。
  • 学习提交-揭示模式,该模式可防止请求者和预言机操纵结果。
  • 将 Switchboard 的 commitIx 和你程序的 request_roll 捆绑到单个交易中,然后将 revealIxsettle_roll 捆绑,使得值在链上落地时被原子性地消费。
  • 从 TypeScript 客户端针对 Solana Devnet 运行完整流程,并从玩家状态 PDA 中读取存储的结果。

你将做什么

  • 设置一个针对 Solana Devnet 的 Anchor 项目(使用公共 RPC,或者如果你有 Quicknode 端点则使用它)。
  • 编写一个掷骰子风格的 Anchor 程序,该程序提交并消费 Switchboard 生成的链上随机数。
  • 部署程序并在 Solana Explorer 上检查它。
  • 从 TypeScript 客户端驱动提交和揭示阶段。
  • 验证链上的新鲜度检查和插槽检查,这些检查保障了设计的安全性。

你需要什么

  • RustSolana CLI,用于构建/部署程序。
  • Anchor CLI
  • Node.js v20 或更高版本以及 npm,用于 TypeScript 客户端和测试。
  • 位于 ~/.config/solana/id.json 的 Solana CLI 钱包密钥对,至少包含 2 个 Devnet SOL,用于程序部署和每次请求的随机性账户租金。
  • 对编写 Anchor 程序 的基本了解。
  • (可选) 如果你想使用专用 Solana Devnet 端点而不是公共端点,则需要 Quicknode 账户
依赖项 版本
@anchor-lang/core 1.0.2
@switchboard-xyz/on-demand 3.10.1
@solana/web3.js 1.98.4
anchor-lang (Rust) 0.32.1
switchboard-on-demand (Rust) 0.10.0

版本兼容性

这些版本是固定的,以匹配 Switchboard SDK 版本矩阵。Rust crate anchor-lang 固定到 0.32.x 系列,因为 Anchor 1.0 与 Switchboard SDK 不兼容(请参阅下面的 Cargo.toml 说明)。TypeScript 包 @anchor-lang/core 可以使用 1.0.x;它具有独立的版本控制。Switchboard On-Demand 仍然需要 @solana/web3.js v1,因为它尚不支持 @solana/kit。使用上述确切版本以避免依赖项冲突。

为什么 Solana 不能原生生成随机数?

Solana 的确定性对于共识至关重要,但这意味着程序内部没有原生的随机性来源。任何看似随机的链上数据(插槽哈希、区块时间戳、账户地址)要么可提前预测,要么会受到生成区块的验证者操纵,这使得它对于任何需要公平性的场景都不安全。

这就是游戏、彩票、NFT 特征分配、随机空投以及任何其他需要公平、不可预测结果的应用的核心问题。标准的解决方法是可验证随机函数 (VRF):一种加密原语,它产生一个随机值以及一个证明,任何人都可以验证该值是正确的生成方式,而不是由请求者或预言机选择的。

Switchboard 按需随机性是如何工作的?

Switchboard 的预言机在可信执行环境 (TEE) 中运行:硬件隔离的 Intel SGX 飞地,预言机操作员无法观察或更改内部正在执行的工作。预言机使用最近的 Solana 插槽哈希作为种子材料,在飞地内计算 VRF,对输出进行签名,并在被问及时将其发布到链上。

流程是一个三阶段模式:

  1. 提交。 你的客户端创建一个 Switchboard 随机性账户,然后发送一个 commitIx 指令。Switchboard 程序将前一个插槽的哈希作为种子写入随机性账户。此时,随机值在数学上已经确定(插槽哈希是固定的),但还没有人计算它,包括预言机。
  2. 揭示。 Switchboard 预言机监视提交,在飞地内离线计算 VRF,对结果进行签名,并等待被请求。你的客户端通过 revealIx 获取签名值并提交到链上。Switchboard 程序将 32 字节的随机值写入随机性账户。
  3. 消费。 你的程序从随机性账户读取已揭示的字节,并将其需要的任何衍生值(骰子点数、NFT 特征、获胜者索引)写入自己的状态。因为 Switchboard 的 get_value() 验证要求当前插槽等于揭示插槽,所以揭示指令和你的消费指令必须在同一个交易中执行

这种共捆绑正是使设计安全的原因。如果揭示和消费步骤在不同的交易中,攻击者可以离线读取揭示的值,然后根据结果决定是否发送消费交易,这将完全违背目的。强制它们进入同一个原子步骤意味着该值在玩家无法中断的单个步骤中落地并被消费。

设置项目

将 Solana CLI 指向 devnet:

solana config set --url devnet

这会使用公共 Solana Devnet 端点,对于本指南中的程序部署和客户端来说已经足够了。如果你想要一个具有更高速率限制和访问 优先费用 的专用端点,创建一个 Quicknode 账户 并改用你的 Solana Devnet 端点的 HTTP URL:

solana config set --url https://example-solana-devnet.quiknode.pro/YOUR_API_KEY/

Quicknode 多链水龙头 向你的钱包充值 Devnet SOL。你至少需要 2 个 SOL 来部署程序并支付每次请求的随机性账户租金和交易费用。

初始化 Anchor 项目

创建一个新的 Anchor 工作空间。传递 --package-manager npm 以便 Anchor 使用 npm 搭建脚手架,并且不会留下 yarn.lock

anchor init qn-switchboard-vrf --package-manager npm
cd qn-switchboard-vrf

默认情况下,Anchor 会创建一些未使用的文件,你应该首先删除它们:

rm programs/qn-switchboard-vrf/src/state.rs
rm programs/qn-switchboard-vrf/tests/test_initialize.rs

打开仓库根目录的工作空间 Cargo.toml,添加一个显式的发布配置,以便程序在启用溢出检查的情况下进行编译(这对于 checked_add 按预期工作很重要):

Cargo.toml

[workspace]
members = ["programs/*"]
resolver = "2"

[profile.release]
overflow-checks = true
lto = "fat"
codegen-units = 1

[profile.release.build-override]
opt-level = 3
incremental = false
codegen-units = 1

配置程序依赖项

打开 programs/qn-switchboard-vrf/Cargo.toml 并将内容替换为:

programs/qn-switchboard-vrf/Cargo.toml

[package]
name = "qn-switchboard-vrf"
version = "0.1.0"
description = "Created with Anchor"
edition = "2021"

[lib]
crate-type = ["cdylib", "lib"]
name = "qn_switchboard_vrf"

[features]
default = []
cpi = ["no-entrypoint"]
no-entrypoint = []
no-idl = []
no-log-ix-name = []
idl-build = ["anchor-lang/idl-build", "switchboard-on-demand/idl-build"]
anchor-debug = []
custom-heap = []
custom-panic = []

[dependencies]
anchor-lang = "0.32.1"
switchboard-on-demand = { version = "=0.10.0", features = ["anchor"] }
## 固定版本:solana-zero-copy 1.1.0 在 spl-token-2022-interface 内部破坏了类型推断
## (关于 PodU64 PartialOrd/PartialEq/From 的 E0283 错误)。1.0.1 是 Switchboard SDK
## 在 Solana 3.x 上可以干净构建的最后一个版本。
solana-zero-copy = "=1.0.1"

[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] }

注意: 构建中最终会有两个 anchor-lang 副本,这是故意的:

  • 程序直接依赖于 anchor-lang = "0.32.1"
  • switchboard-on-demand 0.10.0 带有自己的传递性 anchor-lang,固定到 >=0.31.1, <0.32,Cargo 会将其解析为 0.31.1,并与你的直接依赖项保持分离。

安装 TypeScript 依赖项

将包设置为 ES 模块并添加运行时依赖项。将 package.json 更新为:

package.json

{
  "name": "qn-switchboard-vrf",
  "private": true,
  "license": "ISC",
  "type": "module",
  "scripts": {
    "start": "tsx app/client.ts"
  },
  "dependencies": {
    "@anchor-lang/core": "^1.0.2",
    "@solana/web3.js": "^1.98.4",
    "@switchboard-xyz/on-demand": "^3.10.1",
    "bn.js": "^5.2.1"
  },
  "devDependencies": {
    "@types/bn.js": "^5.1.6",
    "@types/node": "^22.10.0",
    "tsx": "^4.21.0",
    "typescript": "^5.7.3"
  }
}

安装它们:

npm install

更新 tsconfig.json 以使用现代 ESM 解析,以便 Anchor 的 TypeScript 类型干净地解析:

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": ["ES2023"],
    "types": ["node"],
    "strict": true,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "skipLibCheck": true,
    "allowSyntheticDefaultImports": true,
    "forceConsistentCasingInFileNames": true,
    "isolatedModules": true,
    "noEmit": true,
    "allowImportingTsExtensions": true
  },
  "include": ["app/**/*.ts", "tests/**/*.ts"]
}

最后,将 Anchor.toml 指向 Devnet,以便 anchor deployanchor test 知道要针对哪个集群:

Anchor.toml

[toolchain]
package_manager = "npm"

[features]
resolution = true
skip-lint = false

[programs.devnet]
qn_switchboard_vrf = "11111111111111111111111111111111"

[registry]
url = "https://api.apr.dev"

[provider]
cluster = "devnet"
wallet = "~/.config/solana/id.json"

程序 ID 占位符 qn_switchboard_vrf 将在首次构建后被替换,届时 Anchor 会生成一个密钥对。

编写 Anchor 程序

该程序包含三个指令:

  1. initialize 创建一个由调用者拥有的 PlayerState PDA,并将其字段清零。每个用户运行一次。
  2. request_roll 验证一个新的 Switchboard 提交,并在玩家状态上记录随机性账户和提交插槽。
  3. settle_roll 从随机性账户读取已揭示的字节,将它们映射到 1 到 100 之间的数字,并写入结果。

创建模块布局

项目结构将如下所示。你将逐步创建每个文件。

programs/qn-switchboard-vrf/src/
  lib.rs
  error.rs
  instructions.rs
  instructions/
    initialize.rs
    request_roll.rs
    settle_roll.rs
  state/
    mod.rs
    player_state.rs

定义玩家状态 PDA

创建 programs/qn-switchboard-vrf/src/state/mod.rs

programs/qn-switchboard-vrf/src/state/mod.rs

pub mod player_state;

pub use player_state::*;

然后创建 programs/qn-switchboard-vrf/src/state/player_state.rs

programs/qn-switchboard-vrf/src/state/player_state.rs

use anchor_lang::prelude::*;

#[account]
#[derive(InitSpace)]
pub struct PlayerState {
    pub allowed_user: Pubkey,
    pub randomness_account: Pubkey,
    pub commit_slot: u64,
    pub result: u8,
    pub bump: u8,
}

Anchor 的 #[derive(InitSpace)] 宏会自动计算链上大小,这很方便,因为你永远不需要在添加字段时更新手动编写的 SIZE 常量。每个用户恰好得到一个 PlayerState,通过 PDA 种子 [b"playerState", user.key().as_ref()] 确定性寻址。

定义错误代码

创建 programs/qn-switchboard-vrf/src/error.rs

programs/qn-switchboard-vrf/src/error.rs

use anchor_lang::prelude::*;

#[error_code]
pub enum ErrorCode {
    #[msg("随机性已过期。")]
    RandomnessExpired,
    #[msg("随机性已揭示。")]
    RandomnessAlreadyRevealed,
    #[msg("随机性尚未解析。")]
    RandomnessNotResolved,
    #[msg("无效的随机性账户。")]
    InvalidRandomnessAccount,
}

每个错误对应一个不同的不变性违反:

  • RandomnessExpired:提交插槽不完全是 clock.slot - 1。这强制 commitIxrequest_roll 必须在同一个交易中。
  • RandomnessAlreadyRevealed:随机性账户已经有一个值被揭示。重复使用它会不安全。
  • RandomnessNotResolvedget_value() 无法返回值,通常是因为揭示指令不在同一个交易中。
  • InvalidRandomnessAccount:提供的账户与玩家提交的账户不匹配。

添加初始化指令

创建 programs/qn-switchboard-vrf/src/instructions.rs

programs/qn-switchboard-vrf/src/instructions.rs

pub mod initialize;
pub mod request_roll;
pub mod settle_roll;

pub use initialize::*;
pub use request_roll::*;
pub use settle_roll::*;

然后将 programs/qn-switchboard-vrf/src/instructions/initialize.rs 中的代码替换为:

programs/qn-switchboard-vrf/src/instructions/initialize.rs

use anchor_lang::prelude::*;

use crate::state::PlayerState;

#[derive(Accounts)]
pub struct InitializeAccountConstraints<'info> {
    #[account(
        init,
        payer = user,
        space = PlayerState::DISCRIMINATOR.len() + PlayerState::INIT_SPACE,
        seeds = [b"playerState", user.key().as_ref()],
        bump
    )]
    pub player_state: Account<'info, PlayerState>,

    #[account(mut)]
    pub user: Signer<'info>,

    pub system_program: Program<'info, System>,
}

pub fn initialize_handler(context: Context<InitializeAccountConstraints>) -> Result<()> {
    let player_state = &mut context.accounts.player_state;
    player_state.allowed_user = context.accounts.user.key();
    player_state.randomness_account = Pubkey::default();
    player_state.commit_slot = 0;
    player_state.result = 0;
    player_state.bump = context.bumps.player_state;
    Ok(())
}

space 计算将 Anchor 的 8 字节账户鉴别器(PlayerState::DISCRIMINATOR.len())添加到从宏派生的 INIT_SPACE 常量。将 PDA bump 存储在账户上可以让后续指令跳过重新计算。

添加请求掷骰子指令

这是提交端处理程序。创建 programs/qn-switchboard-vrf/src/instructions/request_roll.rs

programs/qn-switchboard-vrf/src/instructions/request_roll.rs

use anchor_lang::prelude::*;
use switchboard_on_demand::RandomnessAccountData;

use crate::error::ErrorCode;
use crate::state::PlayerState;

#[derive(Accounts)]
pub struct RequestRollAccountConstraints<'info> {
    #[account(
        mut,
        seeds = [b"playerState", user.key().as_ref()],
        bump = player_state.bump,
    )]
    pub player_state: Account<'info, PlayerState>,

    /// CHECK: 下面作为 Switchboard RandomnessAccountData 解析;调用者
    /// 声称的 pubkey 会与 AccountInfo 密钥进行比对。
    pub randomness_account_data: UncheckedAccount<'info>,

    #[account(mut)]
    pub user: Signer<'info>,
}

pub fn request_roll_handler(
    context: Context<RequestRollAccountConstraints>,
    randomness_account: Pubkey,
) -> Result<()> {
    require_keys_eq!(
        context.accounts.randomness_account_data.key(),
        randomness_account,
        ErrorCode::InvalidRandomnessAccount
    );

    let clock = Clock::get()?;
    let randomness_data =
        RandomnessAccountData::parse(context.accounts.randomness_account_data.data.borrow())
            .map_err(|_| error!(ErrorCode::InvalidRandomnessAccount))?;

    // 新鲜度:提交必须落在种子插槽之后的立即插槽中。
    // 这强制 commitIx() 和 request_roll 捆绑在同一个交易中。
    let prev_slot = clock
        .slot
        .checked_sub(1)
        .ok_or(error!(ErrorCode::RandomnessExpired))?;
    require!(
        randomness_data.seed_slot == prev_slot,
        ErrorCode::RandomnessExpired
    );

    // 拒绝任何在提交时已经揭示的随机性。
    require!(
        randomness_data.get_value(clock.slot).is_err(),
        ErrorCode::RandomnessAlreadyRevealed
    );

    let player_state = &mut context.accounts.player_state;
    player_state.randomness_account = randomness_account;
    player_state.commit_slot = randomness_data.seed_slot;
    player_state.result = 0;

    msg!("已请求掷骰子。提交插槽:{}", randomness_data.seed_slot);
    Ok(())
}

处理程序执行三项检查:

  1. 传入的账户与调用者提供的 Pubkey 参数匹配。这将链上账户绑定到指令数据内部使用的值。
  2. 随机性账户的 seed_slot 等于前一个插槽。这是新鲜度不变量,强制 commitIxrequest_roll 必须在同一个交易中,因为到下一个插槽时,clock.slot - 1 已经移动了。
  3. 随机性账户还没有揭示的值。重复使用已揭示的随机性账户会让玩家提交到一个他们已经知道的值。

当所有三个检查都通过时,处理程序将随机性账户和提交插槽记录在玩家状态 PDA 上。

添加结算掷骰子指令

创建 programs/qn-switchboard-vrf/src/instructions/settle_roll.rs

programs/qn-switchboard-vrf/src/instructions/settle_roll.rs

use anchor_lang::prelude::*;
use switchboard_on_demand::RandomnessAccountData;

use crate::error::ErrorCode;
use crate::state::PlayerState;

#[derive(Accounts)]
pub struct SettleRollAccountConstraints<'info> {
    #[account(
        mut,
        seeds = [b"playerState", user.key().as_ref()],
        bump = player_state.bump,
    )]
    pub player_state: Account<'info, PlayerState>,

    /// CHECK: 下面作为 Switchboard RandomnessAccountData 解析;密钥
    /// 必须与玩家状态上存储的承诺匹配。
    pub randomness_account_data: UncheckedAccount<'info>,

    #[account(mut)]
    pub user: Signer<'info>,
}

pub fn settle_roll_handler(context: Context<SettleRollAccountConstraints>) -> Result<()> {
    let player_state = &mut context.accounts.player_state;

    require_keys_eq!(
        context.accounts.randomness_account_data.key(),
        player_state.randomness_account,
        ErrorCode::InvalidRandomnessAccount
    );

    let clock = Clock::get()?;
    let randomness_data =
        RandomnessAccountData::parse(context.accounts.randomness_account_data.data.borrow())
            .map_err(|_| error!(ErrorCode::InvalidRandomnessAccount))?;

    // 随机性账户必须仍然代表玩家在 request_roll 中提交的相同承诺插槽。
    require!(
        randomness_data.seed_slot == player_state.commit_slot,
        ErrorCode::RandomnessExpired
    );

    let revealed = randomness_data
        .get_value(clock.slot)
        .map_err(|_| error!(ErrorCode::RandomnessNotResolved))?;

    // 将 32 个随机字节映射到 1..=100。checked_add 证明 +1 不会
    // 溢出(这里的最大输入是 99)。
    let result = (revealed[0] % 100)
        .checked_add(1)
        .ok_or(error!(ErrorCode::RandomnessNotResolved))?;
    player_state.result = result;

    msg!("DICE_RESULT: {}", result);
    Ok(())
}

处理程序强制执行三个属性:

  1. 传入的随机性账户与玩家提交的账户相同。这可以防止在结算时攻击者替换成具有更有利揭示值的不同随机性账户。
  2. 随机性账户上的种子插槽仍然与玩家记录的提交插槽匹配。这可以捕获提交和结算之间的篡改。
  3. get_value(clock.slot) 返回 Ok。Switchboard SDK 要求当前插槽等于揭示插槽,这强制 revealIxsettle_roll 必须在同一个交易中。

只有在所有三个检查都通过后,处理程序才会将第一个随机字节映射到 1 到 100 之间的值,并将其写入玩家状态。

在 lib.rs 中将所有内容组合在一起

programs/qn-switchboard-vrf/src/lib.rs 替换为:

programs/qn-switchboard-vrf/src/lib.rs

pub mod error;
pub mod instructions;
pub mod state;

use anchor_lang::prelude::*;

pub use instructions::*;
pub use state::*;

declare_id!("11111111111111111111111111111111");

#[program]
pub mod qn_switchboard_vrf {
    use super::*;

    pub fn initialize(context: Context<InitializeAccountConstraints>) -> Result<()> {
        instructions::initialize::initialize_handler(context)
    }

    pub fn request_roll(
        context: Context<RequestRollAccountConstraints>,
        randomness_account: Pubkey,
    ) -> Result<()> {
        instructions::request_roll::request_roll_handler(context, randomness_account)
    }

    pub fn settle_roll(context: Context<SettleRollAccountConstraints>) -> Result<()> {
        instructions::settle_roll::settle_roll_handler(context)
    }
}

构建和部署程序

运行首次构建,以便 Anchor 在 target/deploy/qn_switchboard_vrf-keypair.json 生成一个新的程序密钥对:

anchor build

在部署程序之前,declare_id! 宏和 Anchor.toml 中的 [programs.*] 必须与 Anchor 在 target/deploy/qn_switchboard_vrf-keypair.json 中生成的密钥对匹配。在首次构建程序时,你可能会看到 Found incorrect program id declaration 错误。

通过以下命令同步程序密钥:

anchor keys sync

这将更新 lib.rs 中的 declare_id! 宏和 Anchor.toml 中的 [programs.*] 条目,使它们与 Anchor 生成的密钥对匹配。

重新构建以使新的程序 ID 嵌入二进制文件中,然后部署:

anchor build
anchor deploy

部署后,Anchor 会将程序的 IDL 写入 target/idl/qn_switchboard_vrf.json,并将 TypeScript 类型写入 target/types/qn_switchboard_vrf.ts。客户端将导入它们。

编写 TypeScript 客户端

客户端针对 Devnet 端到端地运行完整流程。它从 Solana CLI 配置加载你的钱包,查找 Devnet 的默认 Switchboard 队列,创建一个新的随机性账户,发送创建和提交交易,等待几个插槽让预言机发布值,然后发送结算交易。

创建 app/client.ts

app/client.ts

import * as anchor from "@anchor-lang/core";
import { Keypair, PublicKey } from "@solana/web3.js";
import * as sb from "@switchboard-xyz/on-demand";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import type { QnSwitchboardVrf } from "../target/types/qn_switchboard_vrf.ts";

const here = dirname(fileURLToPath(import.meta.url));
const idlPath = resolve(here, "../target/idl/qn_switchboard_vrf.json");
const idl = JSON.parse(readFileSync(idlPath, "utf-8")) as QnSwitchboardVrf;

const COMMIT_REVEAL_WAIT_MS = 3_000;
const REVEAL_RETRIES = 5;
const REVEAL_BACKOFF_MS = 2_000;

function txUrl(signature: string): string {
  return `https://explorer.solana.com/tx/${signature}?cluster=devnet`;
}

function accountUrl(pubkey: PublicKey): string {
  return `https://explorer.solana.com/address/${pubkey.toBase58()}?cluster=devnet`;
}

async function main() {
  // AnchorUtils.loadEnv 会读取 ~/.config/solana/cli/config.yml 以获取密钥对
  // 路径和 RPC URL。确保 CLI 首先指向 devnet
  // (`solana config set --url devnet` 或 Quicknode 端点)。
  const env = await sb.AnchorUtils.loadEnv();
  const connection = env.connection;
  const wallet = new anchor.Wallet(env.keypair);
  const provider = new anchor.AnchorProvider(connection, wallet, {
    commitment: "confirmed",
  });
  anchor.setProvider(provider);

  const program = new anchor.Program<QnSwitchboardVrf>(idl, provider);
  const queue = await sb.getDefaultQueue(connection.rpcEndpoint);
  const sbProgram = queue.program;

  const [playerStatePda] = PublicKey.findProgramAddressSync(
    [Buffer.from("playerState"), wallet.publicKey.toBuffer()],
    program.programId,
  );
  console.log(`program:      ${accountUrl(program.programId)}`);
  console.log(`wallet:       ${accountUrl(wallet.publicKey)}`);
  console.log(`player PDA:   ${accountUrl(playerStatePda)}`);
  console.log(`queue:        ${accountUrl(queue.pubkey)}`);

  // 步骤 1. 如果该钱包是首次运行,则创建玩家状态 PDA。
  const existing = await connection.getAccountInfo(playerStatePda);
  if (!existing) {
    console.log("正在初始化玩家状态...");
    const initSig = await program.methods
      .initialize()
      .accountsPartial({
        user: wallet.publicKey,
        systemProgram: anchor.web3.SystemProgram.programId,
      })
      .rpc();
    console.log(`initialize:   ${txUrl(initSig)}`);
  } else {
    console.log("玩家状态已存在,跳过初始化。");
  }

  // 步骤 2. 为此掷骰子创建一个新的 Switchboard 随机性账户。
  // 这是一个单独的交易,因为 SDK 需要预先分配该账户,
  // 然后才能用于提交。我们显式传递 payer,因为
  // getDefaultQueue 使用一个只读钱包构建其程序,该钱包的公钥
  // 否则会出现在 payer 插槽中。
  const rngKp = Keypair.generate();
  console.log(`randomness:   ${accountUrl(rngKp.publicKey)}`);

  const [randomness, createIx] = await sb.Randomness.create(
    sbProgram,
    rngKp,
    queue.pubkey,
    wallet.publicKey,
  );
  const createTx = await sb.asV0Tx({
    connection,
    ixs: [createIx],
    signers: [env.keypair, rngKp],
    payer: wallet.publicKey,
    computeUnitPrice: 75_000,
    computeUnitLimitMultiple: 1.3,
  });
  const createSig = await connection.sendTransaction(createTx);
  await connection.confirmTransaction(createSig, "confirmed");
  console.log(`create tx:    ${txUrl(createSig)}`);

  // 步骤 3. 提交阶段。commitIx + request_roll 必须落在同一个插槽中,
  // 因为程序会检查 `seed_slot == clock.slot - 1`。
  const commitIx = await randomness.commitIx(queue.pubkey, wallet.publicKey);
  const requestRollIx = await program.methods
    .requestRoll(rngKp.publicKey)
    .accountsPartial({
      randomnessAccountData: rngKp.publicKey,
      user: wallet.publicKey,
    })
    .instruction();

  const commitTx = await sb.asV0Tx({
    connection,
    ixs: [commitIx, requestRollIx],
    signers: [env.keypair],
    payer: wallet.publicKey,
    computeUnitPrice: 75_000,
    computeUnitLimitMultiple: 1.3,
  });
  const commitSig = await connection.sendTransaction(commitTx);
  await connection.confirmTransaction(commitSig, "confirmed");
  console.log(`commit tx:    ${txUrl(commitSig)}`);

  // 步骤 4. 给预言机几个插槽来观察提交并发布值。
  await new Promise((r) => setTimeout(r, COMMIT_REVEAL_WAIT_MS));

  let revealIx;
  for (let attempt = 1; attempt <= REVEAL_RETRIES; attempt++) {
    try {
      revealIx = await randomness.revealIx(wallet.publicKey);
      break;
    } catch (revealError) {
      if (attempt === REVEAL_RETRIES) {
        throw revealError;
      }
      console.log(`揭示未就绪(第 ${attempt} 次尝试);正在重试...`);
      await new Promise((r) => setTimeout(r, REVEAL_BACKOFF_MS));
    }
  }
  if (!revealIx) {
    throw new Error("预言机未生成揭示指令");
  }

  // 步骤 5. 结算阶段。revealIx + settle_roll 也必须位于同一个交易中
  // 以便程序与揭示原子性地消费该值。
  const settleIx = await program.methods
    .settleRoll()
    .accountsPartial({
      randomnessAccountData: rngKp.publicKey,
      user: wallet.publicKey,
    })
    .instruction();

  const settleTx = await sb.asV0Tx({
    connection,
    ixs: [revealIx, settleIx],
    signers: [env.keypair],
    payer: wallet.publicKey,
    computeUnitPrice: 75_000,
    computeUnitLimitMultiple: 1.3,
  });
  const settleSig = await connection.sendTransaction(settleTx);
  const settleStatus = await connection.confirmTransaction(
    settleSig,
    "confirmed",
  );
  console.log(`settle tx:    ${txUrl(settleSig)}`);

  // 步骤 6. 从 PDA 读取结果。程序还会发出
  // "DICE_RESULT: N" 日志行;将其作为健全性检查。
  const playerState = await program.account.playerState.fetch(playerStatePda);
  console.log(`rolled ${playerState.result}`);

  const txDetail = await connection.getTransaction(settleSig, {
    commitment: "confirmed",
    maxSupportedTransactionVersion: 0,
  });
  const resultLog = txDetail?.meta?.logMessages?.find((line) =>
    line.includes("DICE_RESULT:"),
  );
  if (resultLog) {
    console.log("Log:", resultLog);
  }

  if (settleStatus.value.err) {
    throw new Error(`settle failed: ${JSON.stringify(settleStatus.value.err)}`);
  }
}

main().catch((thrown) => {
  console.error(thrown);
  process.exit(1);
});

关于此代码的一些说明:

  • sb.AnchorUtils.loadEnv() 会读取你之前设置的相同 Solana CLI 配置,这就是为什么钱包路径和 RPC URL 永远不会硬编码。如果你想使用不同的密钥对,请在运行前设置 KEYPAIR_PATH 环境变量。Switchboard SDK 会读取它作为 CLI 配置钱包路径的覆盖项。
  • sb.getDefaultQueue() 返回 RPC 端点所服务集群的默认 Switchboard 预言机队列。返回的 queue.program 是一个 Switchboard 构建的 Anchor 程序,用于构造 commitIxrevealIx
  • 随机性账户是在一个独立的交易中创建的。将其与提交捆绑在一起会失败,因为 SDK 需要账户在 commitIx 可以写入之前已经存在。
  • 3 秒的等待加上重试循环给预言机时间来观察提交并发布签名的值。在 Devnet 上,这通常端到端在 5 秒内完成。
  • 传递给 sb.Randomness.createrandomness.commitIxpayer 参数是显式提供的,因为队列的内部程序是使用只读钱包构建的。省略它会将链上付款人设置为一个占位符密钥,签名将不匹配。

运行端到端流程

一切就绪后,运行客户端:

npm start

预期输出(签名和地址将不同):

program:      https://explorer.solana.com/address/4ge1...?cluster=devnet
wallet:       https://explorer.solana.com/address/Abcd...?cluster=devnet
player PDA:   https://explorer.solana.com/address/Efgh...?cluster=devnet
queue:        https://explorer.solana.com/address/EYiA...?cluster=devnet
Initializing player state...
initialize:   https://explorer.solana.com/tx/2nRq...?cluster=devnet
randomness:   https://explorer.solana.com/address/Hijk...?cluster=devnet
create tx:    https://explorer.solana.com/tx/3xYz...?cluster=devnet
commit tx:    https://explorer.solana.com/tx/4dYp...?cluster=devnet
settle tx:    https://explorer.solana.com/tx/5wQv...?cluster=devnet
rolled 73
Log: Program log: DICE_RESULT: 73

打开任何 Solana Explorer 链接以检查交易。结算交易是最有趣的:你将看到按顺序执行的两个指令,Switchboard: reveal 后跟 qn_switchboard_vrf: settle_roll,以及一个包含掷骰子值的程序日志行。

结果也会持久存储在链上的 PlayerState PDA 中。任何未来的指令都可以读取它,而无需重新执行 VRF 流程。

在你自己的程序中使用随机值

RandomnessAccountData::get_value() 返回的 32 个字节是加密安全的且相互独立的,这意味着你可以根据需要对其进行切片(这里的 clock 来自周围处理程序中的 let clock = Clock::get()?,与 settle_roll 中相同):

let value: [u8; 32] = randomness_data.get_value(clock.slot)?;

// 掷Coin:偶数为正面,奇数为反面
let flip = value[0] % 2;

// 掷骰子 1 到 6
let roll = (value[1] % 6) + 1;

// 0 到 99 的随机数
let score = value[2] % 100;

// 500 个元素的数组的随机索引(使用两个字节)
let index = u16::from_le_bytes([value[3], value[4]]) as usize % 500;

生产环境考量

拥堵情况下的优先费用

在主网测试版负载下,你的提交或结算交易可能不会在下一个插槽中落地。Switchboard 的 get_value() 检查对于插槽时间非常严格,因此延迟的结算会因 RandomnessNotResolved 而失败。

Quicknode 优先费用 API 返回当前网络范围的费用建议,以便你的交易可靠地落地:

const res = await fetch(QUICKNODE_RPC, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    jsonrpc: "2.0",
    id: 1,
    method: "qn_estimatePriorityFees",
    params: { last_n_blocks: 100, account: PROGRAM_ID.toBase58() },
  }),
});
const { result } = await res.json();
const priorityFee = result.per_compute_unit.medium;

priorityFee 作为 computeUnitPrice 传递给 sb.asV0Tx()。有关完整演练,请参阅 优先费用指南

不要重复使用随机性账户

每个随机性账户应恰好用于一个提交-揭示周期。request_roll 中的 RandomnessAlreadyRevealed 检查可以防止意外重用,但更安全的模式是为每个请求生成一个新的 Keypair,就像上面的客户端所做的那样。支付给随机性账户的租金会保留在其中,直到你决定关闭它。

避免模数偏差

演示使用 revealed[0] % 100 将一个字节映射到 1..=100,这对于教程来说没问题,但在生产环境中会有偏差。一个 u8 包含 256 个值,因此 % 100 给结果 0..=55 各 3 个原像(例如,0 ← {0, 100, 200}),而结果 56..=99 只有 2 个。结果 1..=56 的可能性大约是 57..=100 的 1.5 倍。对于任何要求公平性的场景,从更宽的整数派生值:

let bytes: [u8; 4] = revealed[0..4].try_into().unwrap();
let n = u32::from_le_bytes(bytes);
let roll = ((n % 100) as u8)
    .checked_add(1)
    .ok_or(error!(ErrorCode::RandomnessNotResolved))?;

使用 u32,残余偏差大约为 100 / 2^32,可以忽略不计。要实现零偏差,请使用拒绝采样并丢弃高于适合整数的 100 的最大倍数的值。

生产环境加固

对于真正的游戏或彩票,还应添加:

  • request_rollsettle_roll 进行白名单或签名者检查,以便只有预期的用户(或游戏程序)才能触发对其 PDA 的掷骰子。
  • 结算步骤的应用级幂等性:如果结算交易落地两次,第二次应该是一个空操作,而不是重新掷骰子。
  • 计算单位限制要适应最坏情况。客户端中的 1.3 倍乘数对于骰子演示来说是一个合理的默认值,但更复杂的揭示后逻辑可能需要更高的限制。

总结

你现在拥有了一个完整的、可运行的模式,用于使用 Switchboard VRF 在 Solana 上生成链上随机数。相同的三步结构(初始化、提交、结算)可以扩展到任何需要公平性的应用程序中。你从 RandomnessAccountData 中读出的 32 个随机字节是独立的且加密安全的,因此你可以从一次掷骰子中派生出任意数量的独立结果。

如果你在生产环境中看到 RandomnessExpiredRandomnessNotResolved 错误,要验证的第一件事是共捆绑规则是否仍在你的客户端中成立。链上插槽检查会为你强制执行。

常见问题

为什么 Solana 程序不能原生生成随机数?
Solana 本质上是确定性的:每个验证者在处理交易时必须得到相同的结果,以便共识工作。程序可能用作随机种子的任何链上值(插槽哈希、时间戳、账户地址)要么可提前预测,要么会受到生成区块的验证者操纵。来自外部预言机的可验证随机函数 (VRF) 提供加密安全的随机性,任何人都可以验证该值不是由请求者或预言机选择的。

为什么 commit 和 request_roll 必须在同一个交易中?
Switchboard 的 commitIx 将 seed_slot = clock.slot - 1 写入随机性账户,而程序的 request_roll 会检查 seed_slot == clock.slot - 1。如果这两个指令在单独的交易中,那么到第二个交易时 clock.slot 已经前进,检查将失败并显示 RandomnessExpired。捆绑它们保证了新鲜度不变量成立。

为什么 reveal 和 settle_roll 必须在同一个交易中?
Switchboard 的 get_value() 方法会检查当前插槽是否等于揭示插槽。如果揭示和结算在单独的交易中运行,攻击者可以在揭示落地后离线读取揭示的值,然后根据结果决定是否发送结算交易(例如,只在获胜掷骰子上结算)。共捆绑使得该值在落地的同一个原子步骤中被消费。

Switchboard V2 VRF 和 Randomness On-Demand 有什么区别?
Switchboard V2 使用回调模式,每次请求需要数百个链上验证指令,并且消耗大量计算预算。Randomness On-Demand 使用基于 TEE 的预言机和简单的提交-揭示模式,每次请求成本约为 0.002 SOL,并且只需在你的交易中添加一个揭示指令。所有新项目都应使用 On-Demand。

我可以针对 localnet 或 LiteSVM 运行吗?
不能。Switchboard 的预言机网络仅在 devnet 和 mainnet-beta 上运行,因此没有预言机可以在 localnet 或 LiteSVM 上观察提交并发布值。Anchor 程序本身可以在本地编译和运行,但提交-揭示流程需要 devnet 或 mainnet-beta。对于程序逻辑的本地单元测试,你可以伪造 RandomnessAccountData 字节,但这些测试不练习真正的握手。

我可以将相同的 32 个随机字节用于多个目的吗?
可以。32 字节值中的每个字节都是独立随机的,因此你可以使用不同的字节(或字节范围)来做出独立的随机决策。例如,字节 0 用于掷Coin,字节 1 用于掷骰子,字节 2 和 3 组合成 u16 作为数组索引。字节之间没有相关性,不会让一个结果泄漏关于另一个结果的信息。

资源

  • 原文链接: quicknode.com/guides/sol...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
QuickNode
QuickNode
江湖只有他的大名,没有他的介绍。