Weak Randomness Remediation (Solana)
How to eliminate weak randomness vulnerabilities in Solana programs by replacing predictable on-chain entropy sources with Verifiable Random Functions (VRF) or commit-reveal schemes.
Weak Randomness Remediation (Solana)
Overview
Solana programs that derive randomness from Clock::unix_timestamp, Clock::slot, transaction signatures, or account data hashes are exploitable by validators and motivated attackers who can predict or influence these values. The correct solution depends on the security requirements and the frequency of random number requests.
Recommended Fix
Option 1: Switchboard VRF (Production Standard)
Switchboard VRF provides cryptographically proven randomness via a two-transaction pattern:
- The program requests a random value
- A Switchboard oracle fulfills the request in a subsequent transaction
// 1. Request randomness
use switchboard_v2::{VrfRequestRandomness, VrfAccountData};
pub fn request_randomness(ctx: Context<RequestRandomness>) -> Result<()> {
let switchboard_program = ctx.accounts.switchboard_program.to_account_info();
let request = VrfRequestRandomness {
authority: ctx.accounts.authority.to_account_info(),
vrf: ctx.accounts.vrf.to_account_info(),
oracle_queue: ctx.accounts.oracle_queue.to_account_info(),
queue_authority: ctx.accounts.queue_authority.to_account_info(),
data_buffer: ctx.accounts.data_buffer.to_account_info(),
permission: ctx.accounts.permission.to_account_info(),
escrow: ctx.accounts.escrow.clone(),
payer_wallet: ctx.accounts.payer_wallet.clone(),
payer_authority: ctx.accounts.payer_authority.to_account_info(),
recent_blockhashes: ctx.accounts.recent_blockhashes.to_account_info(),
program_state: ctx.accounts.program_state.to_account_info(),
token_program: ctx.accounts.token_program.to_account_info(),
};
let vrf_key = ctx.accounts.vrf.key();
let authority_seed = vrf_key.as_ref();
let seeds = &[authority_seed, &[ctx.bumps.state]];
request.invoke_signed(switchboard_program, 0, 220, None, &[seeds])?;
Ok(())
}
// 2. Consume randomness (called in a separate transaction after oracle fulfills)
pub fn consume_randomness(ctx: Context<ConsumeRandomness>) -> Result<()> {
let vrf_account = &ctx.accounts.vrf;
let vrf = VrfAccountData::new(vrf_account)?;
if vrf.status != VrfStatus::StatusCallbackSuccess {
return Err(error!(ErrorCode::VrfNotFulfilled));
}
let random_result = vrf.get_result()?;
let random_value = u64::from_le_bytes(random_result[..8].try_into().unwrap());
// Use random_value for lottery, NFT trait, etc.
let state = &mut ctx.accounts.game_state;
state.winner_index = random_value % state.num_participants;
Ok(())
}
Option 2: Commit-Reveal Scheme (No External Dependency)
When a VRF oracle is not acceptable due to cost or latency, a commit-reveal scheme provides front-running resistance at the cost of requiring two transactions per randomness request. This still does not protect against validator manipulation.
// Phase 1: User commits a hash of their secret
#[derive(Accounts)]
pub struct Commit<'info> {
#[account(mut)]
pub user: Signer<'info>,
#[account(
init,
payer = user,
space = 8 + 32 + 8 + 1,
seeds = [b"commitment", user.key().as_ref()],
bump,
)]
pub commitment: Account<'info, CommitmentRecord>,
pub system_program: Program<'info, System>,
}
pub fn commit(ctx: Context<Commit>, commitment_hash: [u8; 32]) -> Result<()> {
let clock = Clock::get()?;
let commitment = &mut ctx.accounts.commitment;
commitment.hash = commitment_hash;
commitment.slot = clock.slot;
commitment.revealed = false;
Ok(())
}
// Phase 2: User reveals secret; combine with on-chain entropy
#[derive(Accounts)]
pub struct Reveal<'info> {
pub user: Signer<'info>,
#[account(
mut,
seeds = [b"commitment", user.key().as_ref()],
bump,
constraint = !commitment.revealed @ ErrorCode::AlreadyRevealed,
// Require at least 5 slots to prevent same-block front-running
constraint = Clock::get().unwrap().slot > commitment.slot + 5 @ ErrorCode::RevealTooEarly,
)]
pub commitment: Account<'info, CommitmentRecord>,
#[account(mut)]
pub game_state: Account<'info, GameState>,
}
pub fn reveal(ctx: Context<Reveal>, secret: [u8; 32]) -> Result<()> {
let commitment = &mut ctx.accounts.commitment;
// Verify the secret matches the commitment
let computed_hash = solana_program::hash::hash(&secret).to_bytes();
require!(computed_hash == commitment.hash, ErrorCode::InvalidSecret);
// Mix secret with recent slot hash for additional entropy
let clock = Clock::get()?;
let entropy = solana_program::hash::hashv(&[
&secret,
&clock.slot.to_le_bytes(),
ctx.accounts.user.key.as_ref(),
]);
let random_value = u64::from_le_bytes(entropy.to_bytes()[..8].try_into().unwrap());
let game = &mut ctx.accounts.game_state;
game.outcome = random_value % game.total_options;
commitment.revealed = true;
Ok(())
}
Alternative Mitigations
Delay-based randomness: Add a minimum delay between the initiation of a random event and its resolution. While this does not prevent prediction, it reduces the practical exploitability by requiring the attacker to hold positions for longer periods.
Multiple entropy sources: Combining multiple independent entropy sources (user secret, historical slot hashes, multiple participants’ contributions) increases the difficulty of manipulation, though it does not provide cryptographic guarantees.
Bounded impact design: If the outcome of a random draw has bounded value (e.g., a lottery with a small prize pool), the cost of validator manipulation may exceed the potential profit, making the vulnerability economically non-exploitable.
Common Mistakes
-
Using
Clock::unix_timestampas a seed: Timestamps are observable and approximately predictable before transaction submission. -
Using transaction signature bytes as entropy: Transaction signatures are determined by the signing key, which the submitter controls.
-
Hash-of-account-data as randomness: Account data is public and the hash can be computed before the transaction.
-
Not implementing the two-transaction VRF pattern: Requesting and consuming randomness in the same transaction defeats the purpose of VRF — the result must be committed in a prior block.
-
Using Switchboard VRF without checking the fulfillment status: Always verify
vrf.status == VrfStatus::StatusCallbackSuccessbefore consuming the result.