CPI Integration Guide
A full integration end-to-end: a small lottery program on-chain, a TypeScript client that fetches drand and submits the settle. Runnable against devnet.
Reference implementation: example-lottery/ on GitHub.
What we’re building
Section titled “What we’re building”A single-round lottery:
- Anyone can call
buy_ticket(lamports)to put SOL into the prize pool - After a deadline, anyone can call
settle(round, signature)which:- Verifies the drand round on-chain via Alea CPI
- Uses the 32-byte randomness to pick a winning ticket index
- Transfers the prize to that ticket’s holder
- No admin, no oracle, no operator. Whoever submits the valid round on time pays gas; the lottery participants share the entropy guarantee.
On-chain program
Section titled “On-chain program”Two accounts matter: Lottery holds state, and a separate pool System-owned PDA holds the prize SOL. Keeping state and lamports in separate accounts is the idiomatic Anchor pattern — System Program transfers require the destination to be System-owned, and Anchor #[account] types are owned by your program.
use anchor_lang::prelude::*;use alea_sdk::{self, AleaVerifier};
declare_id!("11111111111111111111111111111111"); // placeholder — replace with your program's declare_id
#[account]pub struct Lottery { pub deadline: i64, // unix timestamp after which settle is allowed pub ticket_count: u64, // monotonic counter pub prize_lamports: u64, // tracks the expected balance of `pool` pub winner: Option<Pubkey>, // None until settled pub bump: u8, pub pool_bump: u8, // bump for the prize-pool PDA}
#[account]pub struct Ticket { pub lottery: Pubkey, pub holder: Pubkey, pub idx: u64, pub bump: u8,}The prize pool is a PDA with no data, owned by the System Program, derived from [b"pool", lottery.key().as_ref()]. Lamports transfer in via system_program::transfer on buy_ticket, and out via the program signing with seeds on settle.
buy_ticket
Section titled “buy_ticket”#[program]pub mod lottery { use super::*;
pub fn buy_ticket(ctx: Context<BuyTicket>, lamports: u64) -> Result<()> { let lottery = &mut ctx.accounts.lottery; require!( Clock::get()?.unix_timestamp < lottery.deadline, LotteryError::Closed ); require!(lamports > 0, LotteryError::ZeroAmount);
// Transfer from buyer to the System-owned pool PDA. The destination // must be System-owned for system_program::transfer to succeed. anchor_lang::system_program::transfer( CpiContext::new( ctx.accounts.system_program.to_account_info(), anchor_lang::system_program::Transfer { from: ctx.accounts.buyer.to_account_info(), to: ctx.accounts.pool.to_account_info(), }, ), lamports, )?;
let ticket = &mut ctx.accounts.ticket; ticket.lottery = lottery.key(); ticket.holder = ctx.accounts.buyer.key(); ticket.idx = lottery.ticket_count; ticket.bump = ctx.bumps.ticket;
lottery.ticket_count += 1; lottery.prize_lamports += lamports; Ok(()) }}
#[derive(Accounts)]pub struct BuyTicket<'info> { #[account(mut)] pub lottery: Account<'info, Lottery>,
/// CHECK: System-owned prize pool PDA. Receives SOL only; no data. #[account( mut, seeds = [b"pool", lottery.key().as_ref()], bump = lottery.pool_bump, )] pub pool: AccountInfo<'info>,
#[account( init, payer = buyer, space = 8 + 32 + 32 + 8 + 1, seeds = [b"ticket", lottery.key().as_ref(), lottery.ticket_count.to_le_bytes().as_ref()], bump, )] pub ticket: Account<'info, Ticket>,
#[account(mut)] pub buyer: Signer<'info>, pub system_program: Program<'info, System>,}settle — this is where Alea enters
Section titled “settle — this is where Alea enters”pub fn settle(ctx: Context<Settle>, round: u64, signature: [u8; 64]) -> Result<()> { let lottery = &mut ctx.accounts.lottery;
// Preconditions require!(lottery.winner.is_none(), LotteryError::AlreadySettled); require!(Clock::get()?.unix_timestamp >= lottery.deadline, LotteryError::TooEarly); require!(lottery.ticket_count > 0, LotteryError::NoTickets);
// Freshness: drand round must be within 60s of settle-time require!( alea_sdk::is_round_recent( round, &ctx.accounts.alea_config, &ctx.accounts.clock, 60, ), LotteryError::StaleBeacon, );
// CPI to Alea. Returns the 32-byte verified randomness as a raw array. let randomness: [u8; 32] = alea_sdk::cpi::verify( ctx.accounts.alea_program.to_account_info(), ctx.accounts.alea_config.to_account_info(), ctx.accounts.payer.to_account_info(), round, signature, )?;
// Capture FIRST, then do any other CPI. Return data is single-slot. let r = u64::from_le_bytes(randomness[0..8].try_into().unwrap()); let winner_idx = r % lottery.ticket_count;
let winning_ticket = &ctx.accounts.winning_ticket; require!(winning_ticket.idx == winner_idx, LotteryError::WrongTicketAccount); require!(winning_ticket.lottery == lottery.key(), LotteryError::WrongLottery);
// Transfer the whole pool balance to the winner. The pool is a System-owned // PDA, so we sign with its seeds and use System Program transfer. let lottery_key = lottery.key(); let seeds: &[&[u8]] = &[b"pool", lottery_key.as_ref(), &[lottery.pool_bump]]; let prize = lottery.prize_lamports;
anchor_lang::system_program::transfer( CpiContext::new_with_signer( ctx.accounts.system_program.to_account_info(), anchor_lang::system_program::Transfer { from: ctx.accounts.pool.to_account_info(), to: ctx.accounts.winner.to_account_info(), }, &[seeds], ), prize, )?;
lottery.winner = Some(winning_ticket.holder); lottery.prize_lamports = 0; Ok(())}
#[derive(Accounts)]pub struct Settle<'info> { #[account(mut)] pub lottery: Account<'info, Lottery>,
/// CHECK: System-owned prize pool PDA, signed into by the program. #[account( mut, seeds = [b"pool", lottery.key().as_ref()], bump = lottery.pool_bump, )] pub pool: AccountInfo<'info>,
#[account( constraint = winning_ticket.lottery == lottery.key() @ LotteryError::WrongLottery, )] pub winning_ticket: Account<'info, Ticket>,
/// CHECK: lamport-receiver; validated equal to winning_ticket.holder #[account(mut, constraint = winner.key() == winning_ticket.holder @ LotteryError::WrongWinner)] pub winner: AccountInfo<'info>,
// Alea accounts. The seeds::program constraint on alea_config is required // — without it, a fake Config owned by a different program can be substituted. // bump = alea_config.bump reuses Alea's stored canonical bump, saving ~10K CU. pub alea_program: Program<'info, AleaVerifier>, #[account( seeds = [b"config"], bump = alea_config.bump, seeds::program = alea_program.key(), )] pub alea_config: Account<'info, alea_sdk::Config>,
#[account(mut)] pub payer: Signer<'info>, pub clock: Sysvar<'info, Clock>, pub system_program: Program<'info, System>,}
#[error_code]pub enum LotteryError { #[msg("lottery is closed for ticket purchases")] Closed, #[msg("ticket cost must be greater than zero")] ZeroAmount, #[msg("lottery has already been settled")] AlreadySettled, #[msg("settle called before deadline")] TooEarly, #[msg("no tickets sold; settle would divide by zero")] NoTickets, #[msg("drand round is older than 60 seconds")] StaleBeacon, #[msg("ticket idx does not match computed winner idx")] WrongTicketAccount, #[msg("ticket belongs to a different lottery")] WrongLottery, #[msg("winner account does not match ticket holder")] WrongWinner,}TypeScript client
Section titled “TypeScript client”import { Connection, Keypair, PublicKey, Transaction, ComputeBudgetProgram, SYSVAR_CLOCK_PUBKEY,} from "@solana/web3.js";import { AnchorProvider, Program, Wallet, BN } from "@coral-xyz/anchor";import { fetchBeacon, getCurrentRound, getConfigAddress, DEVNET_PROGRAM_ID as ALEA_ID,} from "@alea-drand/sdk";import { IDL as LotteryIDL } from "./idl/lottery";
const connection = new Connection(process.env.RPC_URL!, "confirmed");const settler = Keypair.fromSecretKey(/* your keypair bytes */);const provider = new AnchorProvider(connection, new Wallet(settler), {});const lottery = new Program(LotteryIDL, provider);
const lotteryPda = /* your derived lottery PDA */;const lotteryState = await (lottery.account as any).lottery.fetch(lotteryPda);
// 1. Fetch the latest drand round (past the lottery deadline)const round = getCurrentRound();const beacon = await fetchBeacon(round);
// 2. Compute winner index off-chain so we can pass in the right ticket PDA.// Must match the on-chain math exactly: u64::from_le_bytes(rand[0..8]) % count.// You won't know the verified randomness until after settle runs on-chain,// BUT for the client-side ticket-account lookup you can use the drand// unverifiedRandomness (which equals the verified output for valid sigs).// Note: Anchor deserializes Rust snake_case to camelCase in TS.const unverified = Buffer.from(beacon.unverifiedRandomness, "hex");const r = unverified.readBigUInt64LE(0);const winnerIdx = Number(r % BigInt(lotteryState.ticketCount));
// Derive the winning ticket PDA and the prize-pool PDAconst [winningTicketPda] = PublicKey.findProgramAddressSync( [ Buffer.from("ticket"), lotteryPda.toBuffer(), Buffer.from(new BigUint64Array([BigInt(winnerIdx)]).buffer), ], lottery.programId,);const [poolPda] = PublicKey.findProgramAddressSync( [Buffer.from("pool"), lotteryPda.toBuffer()], lottery.programId,);const winningTicket = await (lottery.account as any).ticket.fetch(winningTicketPda);
// 3. Build settle tx with Alea's required compute budgetconst configPda = getConfigAddress(ALEA_ID);const { SystemProgram } = await import("@solana/web3.js");
const settleIx = await (lottery.methods as any) .settle(new BN(beacon.round.toString()), Array.from(beacon.signature)) .accounts({ lottery: lotteryPda, pool: poolPda, winningTicket: winningTicketPda, winner: winningTicket.holder, aleaProgram: ALEA_ID, aleaConfig: configPda, payer: settler.publicKey, clock: SYSVAR_CLOCK_PUBKEY, systemProgram: SystemProgram.programId, }) .instruction();
const tx = new Transaction() .add(ComputeBudgetProgram.setComputeUnitLimit({ units: 900_000 })) .add(settleIx);
tx.recentBlockhash = (await connection.getLatestBlockhash("confirmed")).blockhash;tx.feePayer = settler.publicKey;tx.sign(settler);
// skipPreflight: true — pairing CU can outrun preflight's blockhash windowconst sig = await connection.sendRawTransaction(tx.serialize(), { skipPreflight: true });await connection.confirmTransaction(sig, "confirmed");Integration notes
Section titled “Integration notes”Every transaction that calls Alea via CPI needs ComputeBudgetProgram.setComputeUnitLimit({ units: 900_000 }) as the first instruction. Alea’s verify is ~407K CU (worst case ~454K), the Solana default is 200K, and you need room for your own settle work on top.
Sending with skipPreflight: true is required. The pairing call’s CU usage can outpace Solana’s preflight blockhash window, so preflight rejects the tx before it ever hits the leader. On-chain execution is identical either way — just skip the simulation. Error-format implications are in Common Pitfalls §5.
Anyone can call settle once the deadline passes. The first valid transaction wins gas, subsequent calls revert with AlreadySettled. This is deliberate — no keeper required. If your app can’t tolerate “anyone can trigger,” gate settle behind a permissioned authority.
About the ticket-account lookup: this example pre-computes the winner index client-side using drand’s unverifiedRandomness field, which equals the verified on-chain output for any valid signature (both are sha256(signature_bytes)). Safe here because the on-chain settle re-checks winning_ticket.idx == winner_idx before paying out. If you lift this pattern into another program, keep the on-chain constraint — without it, the client can lie and the program has no defense. For a general PDA-iteration approach, use remaining_accounts.
One last thing. cpi::verify returns a raw [u8; 32]. Read it into a local variable before any subsequent CPI — Solana return data is single-slot, any later CPI overwrites Alea’s output. See Rust SDK §Return-data ordering footgun.
Related
Section titled “Related”- Reference implementation
- Common Pitfalls — integration footguns
- Rust SDK and TypeScript SDK — full API surface