Sign in

Randomness on chain

Solana programs can't generate random numbers on their own. The reasons are structural rather than solvable by writing cleverer code, and the workarounds you'll see in tutorials are mostly broken in ways that real money has been stolen over. This lecture covers why randomness is hard on chain, how Magicblock VRF solves it cryptographically, and how to wire a consumer program to receive verified random numbers in production.

Why a blockchain can't roll dice

The Solana runtime is a deterministic state machine. Every validator must execute every transaction and arrive at the same result, otherwise consensus breaks. That requirement is incompatible with native randomness. If a program called some rand() function and each validator returned a different value, no two validators would agree on the chain state.

The standard workaround in beginner tutorials is to derive "randomness" from values that already exist on chain. The Clock sysvar's unix timestamp, the current slot number, the SlotHashes sysvar, recent transaction hashes, the caller's pubkey. These are deterministic for everyone reading the chain, so consensus is preserved. They are also all manipulable by the slot leader producing the block.

Why on-chain "randomness" is manipulable A lottery program picks a winner using the Clock sysvar as the seed: let clock = Clock::get()?; let seed = hash(timestamp_bytes, slot_bytes); let winner_idx = u64_from(seed[..8]) % participants.len(); The problem: every input is controlled by or visible to the slot leader. Clock sysvar Set by the slot leader, with some drift tolerance SlotHashes sysvar Leader sees all recent hashes before producing a block tx inclusion Leader picks which transactions land in their slot The attack 1. A malicious slot leader simulates the lottery locally before producing the block. 2. If the outcome picks them (or a colluding wallet), they include the tx. 3. If not, they skip the tx or skip the slot entirely, letting the next leader produce. 4. Sysvar values change at the next slot, so the outcome reshuffles.

The attack does not require the validator to be the lottery's intended target. It requires only that the validator has any financial interest in the outcome and the option to suppress an unfavorable block. The cost of skipping a slot is the lost block reward. If the lottery payout exceeds that, the attack is profitable. For pools worth more than a few SOL, this math works out in the attacker's favor every time.

The fundamental issue: anything visible inside the block is visible to whoever is producing it, and the leader chooses what to publish. You cannot patch this by combining more sources. Any input the program reads is an input the leader can either control or see, and any deterministic function of public inputs produces an output the leader can predict.

What VRF actually is

The Verifiable Random Function (VRF) protocol is a cryptographic construction that produces two things at once: a pseudorandom output, and a proof that the output was generated correctly from a specific seed using a specific private key.

The setup involves a keypair. The party generating randomness, the VRF oracle service, holds the private key. The public key is published on chain in advance. The protocol works like this:

  1. Someone supplies a seed, which can be anything: a slot number, a request ID, a sequence number.
  2. The oracle signs the seed with its private key using the VRF algorithm. This produces a random output and a proof.
  3. Anyone with the public key can verify, by examining the proof, that the output was generated from exactly that seed using exactly that key, and that the oracle had no freedom to choose the output.

The third point is the load-bearing one. The oracle cannot try multiple seeds, see the outputs, and publish only the one it likes, because the seed is committed to in the proof. The oracle cannot reuse a previously favorable output for a new seed, because the proof will not verify. The output is bound to the seed and the key in a way that cannot be forged or selected.

The summary is: the oracle has nowhere to hide. Either it returns the cryptographically determined output, or its proof fails verification and the on-chain program rejects the response.

The request-and-receive cycle

VRF cannot be a single instruction call. The proof must be generated off chain by an entity holding the private key, and that work cannot happen inside a normal program execution. The pattern is asynchronous: your program submits a request in one transaction, and receives the result in a second transaction some slots later.

A VRF request takes two transactions, separated by an off-chain step Your program Magicblock VRF (on-chain program) Oracle service (off chain) CPI: request_randomness TX 1 - user pays the fee writes request PDA: seed, callback target reads the new request (off-chain account subscription) signs seed with private VRF key, produces (bytes, proof) submits (bytes, proof) TX 2 - oracle pays the fee verifies proof against on-chain VRF public key CPI: your callback handler PDA-signed by VRF program stores the result

The implication for your program design is that you cannot use a random number in the same transaction that requests it. The number does not exist yet. Your request_randomness CPI completes by writing a pending request to a PDA, and the user's transaction returns successfully. The number arrives later in a separate transaction via the callback. Anything the program needs to do with the number (pick a winner, reveal an NFT, settle a bet) happens inside that callback rather than the original user transaction. This async shape is the biggest design constraint in working with VRF and it shapes every program you'll build with it.

The number of slots the oracle waits before fulfilling is short. Under normal load, fulfillment happens within a few slots, often within a second of the original request. The user pays for the request transaction in SOL. The oracle pays for the fulfillment transaction itself and charges a fee that gets debited from the requester at request time. This is simpler than Chainlink's pre-funded subscription account model. There is no separate balance to maintain. Each request pays for itself.

Building a consumer program

Your program needs two instructions: one to make the request, and one to receive the callback. The callback is a regular Anchor instruction with a specific signature that the Magicblock VRF program will invoke via CPI.

Add the Magicblock VRF SDK to your Cargo.toml. A request instruction looks like this:

rust
use anchor_lang::prelude::*;
use anchor_lang::solana_program::program::invoke;
use magicblock_vrf_sdk::{
    create_request_randomness_ix,
    RequestRandomnessParams,
    VRF_PROGRAM_ID,
};

declare_id!("...");

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

    pub fn request_random(ctx: Context<RequestRandom>) -> Result<()> {
        // Build the request instruction via the SDK helper
        let ix = create_request_randomness_ix(RequestRandomnessParams {
            payer: ctx.accounts.user.key(),
            callback_program_id: crate::ID,
            callback_discriminator: instruction::ReceiveRandomness::DISCRIMINATOR.to_vec(),
            caller_seed: ctx.accounts.lottery.key().to_bytes(),
            ..Default::default()
        });

        // CPI into the Magicblock VRF program
        invoke(
            &ix,
            &[
                ctx.accounts.user.to_account_info(),
                ctx.accounts.vrf_program.to_account_info(),
                ctx.accounts.system_program.to_account_info(),
                // ... other accounts the SDK requires
            ],
        )?;

        Ok(())
    }
}

#[derive(Accounts)]
pub struct RequestRandom<'info> {
    #[account(mut)]
    pub user: Signer<'info>,

    #[account(mut)]
    pub lottery: Account<'info, Lottery>,

    /// CHECK: The Magicblock VRF program, address pinned
    #[account(address = VRF_PROGRAM_ID)]
    pub vrf_program: UncheckedAccount<'info>,

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

The important fields in RequestRandomnessParams:

  • payer: who pays the request fee. Usually the user.
  • callback_program_id: your program's ID, since the VRF program will CPI back into you.
  • callback_discriminator: the 8-byte Anchor discriminator of the instruction that should receive the randomness. The VRF program uses this to encode the right CPI on fulfillment.
  • caller_seed: 32 bytes of caller-supplied seed. Including the lottery PDA pubkey is a reasonable choice. The VRF service mixes this with its own entropy before signing, so the result is unpredictable to both sides.

The callback handler is a normal instruction with one extra constraint: it can only be called by the Magicblock VRF program. Anchor's Signer constraint plus an address pin handles this:

rust
pub fn receive_randomness(
    ctx: Context<ReceiveRandomness>,
    randomness: [u8; 32],
) -> Result<()> {
    // Whatever you do with the random number happens HERE.
    // The user's original request transaction has long since returned.

    let lottery = &mut ctx.accounts.lottery;
    let participants_len = lottery.participants.len() as u64;

    // Convert the first 8 bytes of randomness to a u64, then modulo
    let winner_idx = u64::from_le_bytes(
        randomness[..8].try_into().unwrap()
    ) % participants_len;

    lottery.winner = lottery.participants[winner_idx as usize];
    Ok(())
}

#[derive(Accounts)]
pub struct ReceiveRandomness<'info> {
    /// CHECK: The Magicblock VRF program signed this CPI via its PDA.
    /// The address pin and Signer constraint together guarantee that
    /// only the VRF program can invoke this handler.
    #[account(signer, address = VRF_PROGRAM_ID)]
    pub vrf_program: UncheckedAccount<'info>,

    #[account(mut)]
    pub lottery: Account<'info, Lottery>,
}

The signer check on vrf_program is what enforces that only the Magicblock VRF program can invoke this callback. If anyone else tries to call receive_randomness directly, the constraint fails and the transaction reverts before reaching your code. You do not write that check manually. Anchor enforces it at handler entry based on the Accounts struct.

Working with the random number

The number you receive is 32 bytes of uniformly random data. To use it for a specific range, slice and modulo:

rust
// Pick a winner from N participants
let winner_idx = u64::from_le_bytes(randomness[..8].try_into().unwrap())
    % participants_len;

// Roll a 20-sided die
let dice_roll = (u64::from_le_bytes(randomness[..8].try_into().unwrap()) % 20) + 1;

// A percentage from 0 to 99
let percent = u64::from_le_bytes(randomness[..8].try_into().unwrap()) % 100;

Modulo introduces very slight bias when the modulus does not divide cleanly into 2^64, but for any modulus you'd use in a program the bias is undetectable. For a 20-sided die, the bias is on the order of one part in 2^59.

Different ranges can use different byte slices. If you need multiple random numbers from a single VRF call, use bytes [0..8] for the first, [8..16] for the second, and so on. The full 32 bytes give you four independent u64 values from a single request.

The number is fully revealed on chain the moment the oracle delivers it. If your program logic depends on keeping the number hidden until later, that is not something VRF gives you. The fulfillment transaction publishes everything, and anyone watching the chain sees the result as soon as it lands. Use cases that need committed-but-hidden randomness need a different protocol.

What can go wrong

Three considerations production programs get wrong.

The callback can fail. If your receive_randomness handler runs out of compute units, or panics on a checked-arithmetic overflow, or hits any other Anchor constraint failure, the callback transaction reverts. The Magicblock VRF program records the failure, but your program does not receive the randomness. Keep callbacks minimal. Store the result and any cheap derived values. Save complex logic for a separate user-triggered instruction that reads from storage. A callback that reverts is a request that you paid for and got nothing from.

Reorgs and re-fulfillment. A request submitted near the tip of the chain can in rare cases see its fulfillment transaction land in a forked branch. The same VRF response would still be valid, since the seed and proof are deterministic, but your program might process the same request twice if it doesn't track which requests have already been fulfilled. The Magicblock VRF program guards against this at its layer by closing the request account on fulfillment. Your program can add a second layer of protection by storing the request ID on the lottery state and rejecting fulfillments that target an already-resolved request.

You cannot use the random number in the request transaction. A common beginner mistake is to write something like "request a number and then check if the user won." There is no number yet. The check has to happen in the callback. The user either sends a follow-up transaction to claim their prize or the callback automatically settles the outcome.