23 min read
Overview
Solana validators are deterministic by design, meaning every validator must arrive at the same result when processing a transaction. This makes traditional random number generation impossible inside a program. Switchboard Randomness On-Demand solves this with a Verifiable Random Function (VRF) that produces tamper-proof random values anyone can verify.
This guide walks Solana developers through building a complete Anchor program that requests a random number from Switchboard and stores it onchain as a value between 1 and 100, and a TypeScript client that drives the full commit-reveal flow against Solana Devnet.
- Build a Solana Anchor program with three instructions (
initialize,request_roll,settle_roll) that uses Switchboard Randomness On-Demand to generate verifiable random numbers - Learn the commit-reveal pattern that prevents both the requester and the oracle from manipulating the outcome
- Bundle the Switchboard
commitIxand your program'srequest_rollinto a single transaction, then bundlerevealIxandsettle_rollso the value is consumed atomically when it lands onchain - Run the full flow from a TypeScript client against Solana Devnet and read the stored result from the player state PDA
What You Will Do
- Set up an Anchor project targeting Solana Devnet (with the public RPC, or a Quicknode endpoint if you have one)
- Write a Dice Roll style Anchor program that commits to and consumes Switchboard generated onchain random numbers
- Deploy the program and inspect it on Solana Explorer
- Drive the commit and reveal phases from a TypeScript client
- Verify the onchain freshness and slot checks that make the design safe
What You Will Need
- Rust and the Solana CLI for building/deploying the program
- Anchor CLI
- Node.js v20 or newer and npm, for the TypeScript client and tests
- A Solana CLI wallet keypair at
~/.config/solana/id.jsonwith at least 2 Devnet SOL, used for the program deploy and per-request randomness account rent - A basic understanding of writing Anchor programs
- (Optional) A Quicknode account if you want to use a dedicated Solana Devnet endpoint instead of the public one
| Dependency | Version |
|---|---|
| @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 |
These versions are pinned to match the Switchboard SDK version matrix. The Rust crate anchor-lang is pinned to the 0.32.x line because Anchor 1.0 is incompatible with the Switchboard SDK (see the Cargo.toml notes below). The TypeScript package @anchor-lang/core can use 1.0.x; it has independent versioning. Switchboard On-Demand still requires @solana/web3.js v1 as it does not yet support @solana/kit. Use the exact versions above to avoid dependency conflicts.
Why Can't Solana Generate Random Numbers Natively?
Solana's determinism is essential for consensus, but it means there is no native source of randomness inside a program. Any onchain data that looks random (slot hashes, block timestamps, account addresses) is either predictable in advance or manipulable by the validator producing the block, which makes it unsafe for anything where fairness matters.
This is the core problem for games, lotteries, NFT trait assignment, random airdrops, and any other application that needs a fair, unpredictable outcome. The standard fix is a Verifiable Random Function (VRF): a cryptographic primitive that produces a random value along with a proof that anyone can verify the value was generated correctly and not chosen by the requester or the oracle.
How Does Switchboard On-Demand Randomness Work?
Switchboard oracles run inside Trusted Execution Environments (TEEs): hardware-isolated Intel SGX enclaves where the oracle operator cannot observe or alter the work being done inside. The oracle uses a recent Solana slot hash as the seed material, computes a VRF inside the enclave, signs the output, and posts it onchain when asked.
The flow is a three-phase pattern:
-
Commit. Your client creates a Switchboard randomness account, then sends a
commitIxinstruction. The Switchboard program writes the previous slot's hash into the randomness account as the seed. At this point the random value is mathematically determined (the slot hash is fixed) but nobody has computed it yet, including the oracle. -
Reveal. A Switchboard oracle watches the commit, computes the VRF offchain inside its enclave, signs the result, and waits to be asked. Your client fetches the signed value via
revealIxand submits it onchain. The Switchboard program writes the 32-byte random value into the randomness account. -
Consume. Your program reads the revealed bytes from the randomness account and writes whatever derived value it needs (a dice roll, an NFT trait, a winner index) to its own state. Because Switchboard's
get_value()validation requires the current slot to equal the reveal slot, the reveal instruction and your consume instruction must execute in the same transaction.
This co-bundling is what makes the design safe. If the reveal and consume steps were in separate transactions, an attacker could read the revealed value offchain and decide whether to send the consume transaction based on the outcome, which would defeat the whole purpose. Forcing them into the same atomic step means the value lands onchain and gets consumed in a single step the player cannot interrupt.
Set Up the Project
Point the Solana CLI at devnet:
solana config set --url devnet
That uses the public Solana Devnet endpoint, which is fine for the program deploy and the client in this guide. If you want a dedicated endpoint with higher rate limits and access to Priority Fees, create a Quicknode account and use the HTTP URL for your Solana Devnet endpoint instead:
solana config set --url https://example-solana-devnet.quiknode.pro/YOUR_API_KEY/
Fund your wallet with Devnet SOL from the Quicknode Multi-Chain Faucet. You will need at least 2 SOL to deploy the program and to cover per-request randomness account rent and transaction fees.
Initialize the Anchor Project
Create a new Anchor workspace. Pass --package-manager npm so Anchor scaffolds with npm and does not leave a yarn.lock behind:
anchor init qn-switchboard-vrf --package-manager npm
cd qn-switchboard-vrf
By default, Anchor creates a few unused files you should delete first:
rm programs/qn-switchboard-vrf/src/state.rs
rm programs/qn-switchboard-vrf/tests/test_initialize.rs
Open the workspace Cargo.toml at the repo root and add an explicit release profile so the program compiles with overflow checks (important for checked_add to work as expected):
[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
Configure Program Dependencies
Open programs/qn-switchboard-vrf/Cargo.toml and replace the contents with:
[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"] }
# Pinned: solana-zero-copy 1.1.0 broke type inference inside spl-token-2022-interface
# (E0283 on PodU64 PartialOrd/PartialEq/From). 1.0.1 is the last version the
# Switchboard SDK builds against cleanly on Solana 3.x.
solana-zero-copy = "=1.0.1"
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] }
anchor-lang versions coexistTwo copies of anchor-lang end up in the build, and that's intentional:
- The program depends on
anchor-lang = "0.32.1"directly. switchboard-on-demand 0.10.0carries its own transitiveanchor-langpinned to>=0.31.1, <0.32, which Cargo resolves to0.31.1and keeps separate from your direct dependency.
Install TypeScript Dependencies
Set the package as an ES Module and add the runtime dependencies. Update package.json to:
{
"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"
}
}
Install them:
npm install
Update tsconfig.json to use modern ESM resolution so Anchor's TypeScript types resolve cleanly:
{
"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"]
}
Finally, point Anchor.toml at Devnet so anchor deploy and anchor test know which cluster to target:
[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"
The qn_switchboard_vrf program ID placeholder will be replaced after the first build, when Anchor generates a keypair.
Write the Anchor Program
The program has three instructions:
initializecreates aPlayerStatePDA owned by the caller and zeros its fields. This runs once per user.request_rollvalidates a fresh Switchboard commitment and records the randomness account and commit slot on the player state.settle_rollreads the revealed bytes from the randomness account, maps them to a number in the range 1 to 100, and writes the result.
Create the Module Layout
The project structure will look like this. You will create each file as you go.
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
Define the Player State PDA
Create programs/qn-switchboard-vrf/src/state/mod.rs:
pub mod player_state;
pub use player_state::*;
Then create 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's #[derive(InitSpace)] macro computes the onchain size automatically, which is convenient because you never need to update a hand-rolled SIZE constant when adding a field. Each user gets exactly one PlayerState, addressed deterministically by the PDA seeds [b"playerState", user.key().as_ref()].
Define Error Codes
Create programs/qn-switchboard-vrf/src/error.rs:
use anchor_lang::prelude::*;
#[error_code]
pub enum ErrorCode {
#[msg("Randomness has expired.")]
RandomnessExpired,
#[msg("Randomness already revealed.")]
RandomnessAlreadyRevealed,
#[msg("Randomness not yet resolved.")]
RandomnessNotResolved,
#[msg("Invalid randomness account.")]
InvalidRandomnessAccount,
}
Each error maps to a distinct invariant violation:
RandomnessExpired: the commit slot is not exactlyclock.slot - 1. This forcescommitIxandrequest_rollinto the same transaction.RandomnessAlreadyRevealed: the randomness account has already had a value revealed against it. Reusing it would be unsafe.RandomnessNotResolved:get_value()could not return a value, usually because the reveal instruction was not in the same transaction.InvalidRandomnessAccount: the supplied account does not match the one the player committed to.
Add the Initialize Instruction
Create 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::*;
Then replace the code in 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(())
}
The space calculation adds Anchor's 8-byte account discriminator (PlayerState::DISCRIMINATOR.len()) to the derived INIT_SPACE constant from the macro. Storing the PDA bump on the account lets later instructions skip recomputing it.
Add the Request Roll Instruction
This is the commit-side handler. Create 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: parsed below as a Switchboard RandomnessAccountData; the caller's
/// claimed pubkey is checked against the AccountInfo key.
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))?;
// Freshness: commit must land in the slot immediately after the seed slot.
// This forces commitIx() and request_roll to be bundled in the same tx.
let prev_slot = clock
.slot
.checked_sub(1)
.ok_or(error!(ErrorCode::RandomnessExpired))?;
require!(
randomness_data.seed_slot == prev_slot,
ErrorCode::RandomnessExpired
);
// Reject any randomness that has already been revealed at commit time.
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!("Roll requested. Commit slot: {}", randomness_data.seed_slot);
Ok(())
}
The handler does three checks:
- The account passed in matches the
Pubkeyargument supplied by the caller. This binds the onchain account to the value used inside the instruction data. - The randomness account's
seed_slotequals the previous slot. This is the freshness invariant that forcescommitIxandrequest_rollto be in the same transaction, since by the next slotclock.slot - 1has moved on. - The randomness account does not already have a revealed value. Reusing a revealed randomness account would let a player commit to a value they already know.
When all three pass, the handler records the randomness account and the commit slot on the player state PDA.
Add the Settle Roll Instruction
Create 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: parsed below as a Switchboard RandomnessAccountData; the key
/// must match the commitment stored on the player state.
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))?;
// The randomness account must still represent the same commitment slot
// that the player committed to in 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))?;
// Map 32 random bytes to 1..=100. checked_add proves the +1 cannot
// overflow (the max input here is 99).
let result = (revealed[0] % 100)
.checked_add(1)
.ok_or(error!(ErrorCode::RandomnessNotResolved))?;
player_state.result = result;
msg!("DICE_RESULT: {}", result);
Ok(())
}
The handler enforces three properties:
- The randomness account passed in is the same one the player committed to. This prevents a settle-time substitution where an attacker swaps in a different randomness account with a more favorable revealed value.
- The seed slot on the randomness account still matches the player's recorded commit slot. This catches tampering between commit and settle.
get_value(clock.slot)returnsOk. The Switchboard SDK requires the current slot to equal the reveal slot, which is what forcesrevealIxandsettle_rollinto the same transaction.
Only after all three pass does the handler map the first random byte to a value in 1 to 100 and write it to the player state.
Wire It All Together in lib.rs
Replace programs/qn-switchboard-vrf/src/lib.rs with:
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)
}
}
Build and Deploy the Program
Run the first build so Anchor generates a fresh program keypair at target/deploy/qn_switchboard_vrf-keypair.json:
anchor build
Before deploying a program, the declare_id! macro and [programs.*] in Anchor.toml must match the Anchor generated keypair at target/deploy/qn_switchboard_vrf-keypair.json. You may get a Found incorrect program id declaration on the first build of the program.
Sync the program keys with:
anchor keys sync
This updates the declare_id! macro in lib.rs and the [programs.*] entry in Anchor.toml so they match the keypair Anchor generated.
Rebuild so the new program ID is baked into the binary, then deploy:
anchor build
anchor deploy
After deploy, Anchor writes the program's IDL to target/idl/qn_switchboard_vrf.json and the TypeScript types to target/types/qn_switchboard_vrf.ts. The client imports both.
Write the TypeScript Client
The client runs the full flow end-to-end against Devnet. It loads your wallet from the Solana CLI config, looks up the default Switchboard queue for Devnet, creates a fresh randomness account, sends the create and commit transactions, waits a few slots for the oracle to post the value, and sends the settle transaction.
Create 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 reads ~/.config/solana/cli/config.yml for the keypair
// path and RPC URL. Make sure the CLI is pointed at devnet first
// (`solana config set --url devnet` or a Quicknode endpoint).
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)}`);
// Step 1. Create the player state PDA the first time this wallet runs.
const existing = await connection.getAccountInfo(playerStatePda);
if (!existing) {
console.log("Initializing player state...");
const initSig = await program.methods
.initialize()
.accountsPartial({
user: wallet.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
console.log(`initialize: ${txUrl(initSig)}`);
} else {
console.log("Player state already exists, skipping init.");
}
// Step 2. Create a fresh Switchboard randomness account for this roll.
// This is a separate transaction because the SDK pre-allocates the account
// before it can be used in a commit. We pass payer explicitly because
// getDefaultQueue builds its program with a readonly wallet whose pubkey
// would otherwise end up in the payer slot.
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)}`);
// Step 3. Commit phase. commitIx + request_roll MUST land in the same slot,
// since the program checks `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)}`);
// Step 4. Give the oracle a few slots to observe the commit and post the value.
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(`reveal not ready (attempt ${attempt}); retrying...`);
await new Promise((r) => setTimeout(r, REVEAL_BACKOFF_MS));
}
}
if (!revealIx) {
throw new Error("oracle did not produce a reveal instruction");
}
// Step 5. Settle phase. revealIx + settle_roll must also be in the same tx
// so the program consumes the value atomically with the reveal.
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)}`);
// Step 6. Read the result from the PDA. The program also emits a
// "DICE_RESULT: N" log line; surface it as a sanity check.
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);
});
A few notes on what this code does:
sb.AnchorUtils.loadEnv()reads the same Solana CLI config you set up earlier, which is why the wallet path and RPC URL never appear hardcoded. If you want to use a different keypair, set theKEYPAIR_PATHenvironment variable before running. The Switchboard SDK reads it as an override for the CLI config's wallet path.sb.getDefaultQueue()returns the default Switchboard oracle queue for the cluster the RPC endpoint serves. The returnedqueue.programis a Switchboard-built Anchor program used to constructcommitIxandrevealIx.- The randomness account is created in a standalone transaction. Bundling it with the commit will fail because the SDK needs the account to already exist before
commitIxcan write into it. - The 3-second wait plus retry loop gives the oracle time to observe the commit and post a signed value. On Devnet this is usually under 5 seconds end-to-end.
- The
payerargument tosb.Randomness.createandrandomness.commitIxis passed explicitly because the queue's internal program is built with a readonly wallet. Omitting it would set the onchain payer to a placeholder key and the signature would not match.
Run the End-to-End Flow
With everything in place, run the client:
npm start
Expected output (signatures and addresses will differ):
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
Open any of the Solana Explorer links to inspect the transaction. The settle transaction is the interesting one: you will see two instructions in order, Switchboard: reveal followed by qn_switchboard_vrf: settle_roll, and a program log line with the rolled value.
The result is also persisted onchain in the PlayerState PDA. Any future instruction can read it without re-doing the VRF flow.
Use the Random Value in Your Own Program
The 32 bytes returned by RandomnessAccountData::get_value() are cryptographically secure and independent, which means you can slice them however you need (the clock here comes from let clock = Clock::get()? in the surrounding handler, the same as in settle_roll):
let value: [u8; 32] = randomness_data.get_value(clock.slot)?;
// Coin flip: even = heads, odd = tails
let flip = value[0] % 2;
// Dice roll 1 to 6
let roll = (value[1] % 6) + 1;
// Random number 0 to 99
let score = value[2] % 100;
// Random index into a 500-item array (uses two bytes)
let index = u16::from_le_bytes([value[3], value[4]]) as usize % 500;
Production Considerations
Priority Fees Under Congestion
On mainnet-beta under load, your commit or settle transaction may not land in the next slot. Switchboard's get_value() check is strict about slot timing, so a delayed settle will fail with RandomnessNotResolved.
The Quicknode Priority Fees API returns current network-wide fee recommendations so your transactions land reliably:
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;
Pass priorityFee as computeUnitPrice in sb.asV0Tx(). For a full walkthrough, see the Priority Fees guide.
Do Not Reuse Randomness Accounts
Each randomness account should be used for exactly one commit-reveal cycle. The RandomnessAlreadyRevealed check in request_roll prevents accidental reuse, but the safer pattern is to generate a fresh Keypair for every request, as the client does above. The rent paid into the randomness account stays with it until you decide to close it.
Avoid Modulo Bias
The demo maps one byte to 1..=100 with revealed[0] % 100, which is fine for a tutorial but biased in production. A u8 holds 256 values, so % 100 gives outcomes 0..=55 three preimages each (e.g., 0 ← {0, 100, 200}) and outcomes 56..=99 only two. Outcomes 1..=56 are about 1.5× more likely than 57..=100. For anything where fairness matters, derive the value from a wider integer:
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))?;
With a u32, the residual bias is on the order of 100 / 2^32, which is negligible. For zero bias, use rejection sampling and discard values above the largest multiple of 100 that fits in the integer.
Production Hardening
For a real game or lottery, also add:
- An allowlist or signer check on
request_rollandsettle_rollso only the intended user (or game program) can trigger rolls against their PDA. - Application-level idempotency for the settle step: if the settle transaction lands twice, the second one should be a no-op rather than re-rolling.
- Compute unit limits sized to the worst case. The 1.3x multiple in the client is a reasonable default for the dice demo, but more complex post-reveal logic may need a higher limit.
Wrapping Up
You now have a complete, working pattern for generating onchain random numbers on Solana using Switchboard VRF. The same three-step structure (initialize, commit, settle) extends to any application where fairness matters. The 32 random bytes you read out of RandomnessAccountData are independent and cryptographically secure, so you can derive any number of independent outcomes from a single roll.
If you ever see RandomnessExpired or RandomnessNotResolved errors in production, the first thing to verify is that the co-bundling rule still holds in your client. The onchain slot checks enforce it for you.
Frequently Asked Questions
Why can't Solana programs generate randomness natively?
Solana is deterministic by design: every validator must arrive at the same result when processing a transaction so that consensus works. Any onchain value a program could use as a random seed (slot hash, timestamp, account address) is either predictable in advance or manipulable by the validator producing the block. A Verifiable Random Function from an external oracle provides cryptographically secure randomness that anyone can verify was not chosen by the requester or the oracle.
Why must commit and request_roll be in the same transaction?
Switchboard's commitIx writes seed_slot = clock.slot - 1 into the randomness account, and the program's request_roll checks that seed_slot == clock.slot - 1. If the two instructions were in separate transactions, clock.slot would have advanced by the second tx and the check would fail with RandomnessExpired. Bundling them guarantees the freshness invariant holds.
Why must reveal and settle_roll be in the same transaction?
Switchboard's get_value() method checks that the current slot equals the reveal slot. If reveal and settle ran in separate transactions, an attacker could read the revealed value offchain after the reveal lands and decide whether to send the settle transaction based on the outcome (only settling on winning rolls, for example). Co-bundling makes the value consumed in the same atomic step it lands.
What is the difference between Switchboard V2 VRF and Randomness On-Demand?
Switchboard V2 used a callback pattern that required hundreds of onchain verification instructions per request and ate substantial compute budget. Randomness On-Demand uses TEE-based oracles with a simple commit-reveal pattern that costs about 0.002 SOL per request and adds a single reveal instruction to your transaction. All new projects should use On-Demand.
Can I run this against localnet or LiteSVM?
No. Switchboard's oracle network only runs on devnet and mainnet-beta, so there is no oracle to observe a commit and post a value on localnet or LiteSVM. The Anchor program itself compiles and runs locally, but the commit-reveal flow requires devnet or mainnet-beta. For local unit tests of program logic, you can fabricate RandomnessAccountData bytes, but those tests do not exercise the real handshake.
Can I use the same 32 random bytes for multiple purposes?
Yes. Each byte in the 32-byte value is independently random, so you can use different bytes (or byte ranges) for independent random decisions. For example, byte 0 for a coin flip, byte 1 for a dice roll, bytes 2 and 3 combined as a u16 for an array index. There is no correlation between bytes that would let one outcome leak information about another.
Resources
- Switchboard Randomness On-Demand Docs
- Switchboard On-Demand Examples (GitHub)
- Quicknode Priority Fees API
- Anchor Framework Docs
- Blog post: Verifiable Randomness (VRF) on Solana
We ❤️ Feedback!
Let us know if you have any feedback or requests for new topics. We'd love to hear from you.