Skip to content

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.

A single-round lottery:

  1. Anyone can call buy_ticket(lamports) to put SOL into the prize pool
  2. 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
  3. No admin, no oracle, no operator. Whoever submits the valid round on time pays gas; the lottery participants share the entropy guarantee.

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.

#[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>,
}
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,
}
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 PDA
const [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 budget
const 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 window
const sig = await connection.sendRawTransaction(tx.serialize(), { skipPreflight: true });
await connection.confirmTransaction(sig, "confirmed");

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.