/

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 with channel_key and a [u8; 32].
  • DiceCallback Accounts — 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 is SHA-256("global:dice_callback").

DiceResult struct

programs/my_game/src/lib.rs
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).

programs/my_game/src/lib.rs
#[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

programs/my_game/src/lib.rs
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.settled in 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_callback in a program upgrade.
  • Call deliver_callback again with the stored round_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.