Callback handler
This is where your game logic lives. DICE has already done the expensive work — you read 32 bytes and decide what they mean.
Three parts
A DICE callback is three small pieces: the payload type, the accounts it operates on, and the handler itself. The first two are boilerplate; only the handler has game-specific logic.
DiceResult— a Borsh-serialisable struct withchannel_keyand a[u8; 32].DiceCallbackAccounts — your game PDA, the channel for cross-checking, and whatever else your handler needs.dice_callback— the function Anchor dispatches when DICE CPIs you. Name is load-bearing: the discriminator isSHA-256("global:dice_callback").
DiceResult struct
use anchor_lang::prelude::*;
/// DICE's `deliver_callback` passes this struct as instruction data.
/// Borsh serializes it identically to the positional form
/// `(channel_key: Pubkey, randomness: [u8; 32])` — the struct form is a
/// pure DX win (autocomplete + room to extend without breaking callers).
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, Debug)]
pub struct DiceResult {
/// The DiceChannel PDA that produced this randomness.
pub channel_key: Pubkey,
/// 32 bytes of hardware-backed entropy.
pub randomness: [u8; 32],
}✓ Why the struct form?
Wire-compatible with the older positional form — Borsh doesn't care. But future versions of DICE may add fields (e.g. a round attestation hash). A struct lets us do that without breaking your callback signature.
The Accounts struct
Two accounts are required: your game PDA (mut, with Anchor's seeds + bump check to prove it's the right one), and the dice_channel account (unchecked here, cross-checked in the handler body).
#[derive(Accounts)]
pub struct DiceCallback<'info> {
/// The game PDA this callback settles. Anchor binds it via seeds; the
/// `constraint` blocks double-settlement at compile time.
#[account(
mut,
seeds = [b"game", game.player.as_ref()],
bump,
constraint = !game.settled @ GameError::AlreadySettled,
)]
pub game: Account<'info, GameState>,
/// CHECK: cross-checked in the handler against result.channel_key.
/// Must be the channel you configured for this program.
pub dice_channel: UncheckedAccount<'info>,
}ℹ remaining_accounts
DICE passes your game account as remaining_accounts[0] when it CPIs your program. That means your Anchor #[derive(Accounts)] struct consumes it as the first positional account — there is no extra unpack-from-remaining step in your code.
The handler
pub fn dice_callback(ctx: Context<DiceCallback>, result: DiceResult) -> Result<()> {
// 1. Guard: the passed-in channel account must match the channel that
// signed this result. If not, a malicious caller could spoof
// results from a different channel.
require_keys_eq!(
ctx.accounts.dice_channel.key(),
result.channel_key,
GameError::WrongChannel,
);
// 2. Outcome formula — the single game-specific line. Dice 1..=6:
let g = &mut ctx.accounts.game;
g.outcome = (u32::from_le_bytes([
result.randomness[0],
result.randomness[1],
result.randomness[2],
result.randomness[3],
]) % 6) as u8 + 1;
// 3. Record the raw 32 bytes so anyone can reproduce your formula.
g.randomness = result.randomness;
g.settled = true;
Ok(())
}
#[error_code]
pub enum GameError {
#[msg("Game already settled")]
AlreadySettled,
#[msg("Wrong channel for this callback")]
WrongChannel,
}The only game-specific line is the outcome formula. Everything else is boilerplate that applies to every integration. See the formulas page for ready-to-paste patterns.
Security checks
DICE has already verified that the randomness came from real hardware through a commit-reveal round. Your handler adds two game-level checks on top:
- Channel identity.
require_keys_eq!(dice_channel.key(), result.channel_key)prevents a caller from swapping in a different channel account. Without this, a malicious integrator of another dApp could re-use their own channel's result against yours. - Single-settlement. The
constraint = !game.settledin the Accounts struct means a replayed callback cannot overwrite a finalised round.
What if my handler panics?
The randomness is not lost. It was written to the DiceChannel in a prior transaction — it's still on chain, signed by the hardware, publicly readable. You can:
- Ship a fixed
dice_callbackin a program upgrade. - Call
deliver_callbackagain with the storedround_id. DICE will re-CPI you with the same bytes.
Because the 32 bytes are content-addressed by the round, the retry is deterministic — your handler will see the exact same output the first (failed) call would have seen.