Switchboard VRF
raffl uses Switchboard On-Demand for randomness. The flow is a commit-reveal pattern bridged across two transactions, one slot apart.
This page explains the mechanism, the math, and why each safety check exists. Most of the subtlety in raffl is here, not in the rest of the program.
The two-transaction flow
tx 1 (commit) tx 2 (reveal)
---------------------- ----------------------
| Switchboard commitIx | | Switchboard revealIx |
| raffl::request_draw | | raffl::settle_raffle |
---------------------- ----------------------
| |
| written at slot N | submitted at slot N+1 or later
v v
randomness.seed_slot = N randomness.reveal_slot = clock.slot
raffle.commit_slot = N raffle.state = Settled
raffle.state = Drawing
The client builds both transactions. commitIx and revealIx come from Switchboard's SDK. raffl's two instructions live alongside them in each transaction. Bundling matters: the reveal must happen in the same transaction as settle_raffle so the program reads a fresh randomness value.
request_draw
The creator picks a Switchboard randomness account (commonly created on the fly), runs Switchboard's commitIx, then calls request_draw in the same transaction.
request_draw does five things:
- Owner check. The randomness account's owner must equal the Switchboard On-Demand program ID. Without this, an attacker could pass a self-controlled fake account whose layout matches
RandomnessAccountDataand pre-load chosen randomness. - Parse check. The account data must deserialize to
RandomnessAccountData. The crate is used without theanchorfeature (Anchor 1.0 trait conflict), soRandomnessAccountData::parseruns manually. - Freshness check.
randomness.seed_slot == clock.slot - 1. The commit must have happened in the immediately preceding slot. Without this, a creator could commit far in advance, pre-compute many candidate seeds, and pick the most favorable one. The 1-slot window leaves no time to grind. - Not-already-revealed check.
randomness.get_value(clock.slot)must currently fail. If the value is already known atrequest_draw, the committer could shop randomness across multiple raffles and bind the favorable result. - Pin. Stores
randomness_account_data.key()intoraffle.vrf_accountandseed_slotintoraffle.commit_slot. State flips to Drawing.
A 2026-05 audit caught two attacks that permissionless request_draw enabled: lock-out grief (anyone can pin a randomness account they control and refuse to reveal) and cross-raffle binding (one Switchboard account bound to multiple raffles in one tx, only one can ever settle). Restricting to the creator removes both. A malicious creator can already grief their own raffle, and the timeout-based cancel covers a creator who goes offline.
settle_raffle
settle_raffle is permissionless. Anyone willing to pay the lamports can call it. The client bundles Switchboard's revealIx with this instruction so clock.slot == reveal_slot when the program calls get_value.
Steps:
- State check.
raffle.state == Drawing. - Account binding. The randomness account passed must equal the one stored at
request_draw(raffle.vrf_account). - Owner check again. Same reason as
request_draw. - Slot binding.
randomness.seed_slot == raffle.commit_slot. Catches a different commit being slipped in. - Reveal.
randomness.get_value(clock.slot)returns 32 bytes. The Switchboard implementation only succeeds when the reveal happened in the current slot. - Compute the winner. See math below.
- Verify the passed Ticket. The Ticket PDA passed by the caller must have
ticket_number == derived_winner_index. Anchor's seeds constraint enforces that the ticket's seed bytes match itsticket_numberfield. The handler then ties that index to the value derived independently on-chain. The caller cannot substitute a different ticket. - Write winner.
raffle.winning_ticket = Some(idx),raffle.winner = Some(ticket.buyer), state becomes Settled.
The math
let value: [u8; 32] = randomness_data.get_value(clock.slot)?;
let entropy: u64 = u64::from_le_bytes(value[0..8].try_into().unwrap());
let winner_index = (entropy % raffle.tickets_sold as u64) as u32;
Three things to know:
- The first 8 bytes of the 32-byte randomness value are used as a u64, little-endian. The other 24 bytes are unused. 64 bits of entropy is more than enough for ranges up to 100,000 (the max ticket cap).
- The cast
tickets_sold as u64is lossless (tickets_soldis u32). - The modulus is safe to be non-zero because
request_drawenforcestickets_sold >= min_tickets >= 2.
There is a textbook modulo bias when tickets_sold does not divide evenly into 2^64. For our cap of 100,000 the bias is around 5e-15, well below any practically meaningful threshold. We accept it for simplicity.
Why this and not slot-hash
Slot-hash randomness (using recent_blockhashes or a slot's blockhash) is gameable by validators. A validator producing block N at slot S can choose to skip producing if the resulting randomness is unfavorable, accepting the loss of block reward in exchange for steering an outcome. Switchboard's commit-reveal pattern shifts that trust to a third-party oracle network with explicit slashing for misbehavior, which is meaningfully harder to attack for raffles where the prize exceeds a validator's expected block reward.
For v0.1 (devnet) we hardcode the Switchboard On-Demand devnet PID. Mainnet rotates via a code-level swap and redeploy.