alea-sdk 0.1.0

CPI crate for Alea — drand BN254 BLS randomness verifier on Solana
Documentation

alea-sdk

First production drand BN254 verifier on Solana. One-line CPI, free public good.

Any Anchor program can receive cryptographically verified on-chain randomness with a single CPI call — no oracles, no callbacks, no off-chain coordination.

Warning: Read CAVEATS.md before integrating. This crate is live on Solana devnet with an internal audit passing (avg 8.66/10 across 10 Claude + 5 Codex persona rounds); external paid firm review and mainnet deployment are Phase 5 gates.


Install

[dependencies]
alea-sdk = "0.1"

# Required pin — without this, cargo check / cargo build-sbf fails on
# rustc < 1.95 (current stable is 1.94, Solana BPF is 1.89-dev). See
# Troubleshooting below for context. Re-check after every `cargo update`.
constant_time_eq = "=0.4.2"

or

cargo add alea-sdk
cargo add constant_time_eq@=0.4.2   # required transitive pin; see Troubleshooting

Quick Start — Palestra-Style Integration

use alea_sdk::{self, AleaVerifier};
use anchor_lang::prelude::*;
use anchor_lang::solana_program::sysvar::clock::Clock;

const MAX_BEACON_AGE_SECONDS: u64 = 30; // reject drand rounds older than 30s

#[error_code]
pub enum YourError {
    #[msg("Beacon is too old; use a fresher drand round")]
    StaleBeacon,
}

#[derive(Accounts)]
pub struct SettleMatch<'info> {
    // ... your program's own accounts (e.g., game state, players) ...
    pub game: Account<'info, YourGameState>, // example — your type here

    /// Alea program for randomness verification.
    pub alea_program: Program<'info, alea_sdk::AleaVerifier>,

    /// Alea config PDA — MUST include `seeds::program` or your program is
    /// vulnerable to fake-config substitution (ADR 0034). Anchor does NOT
    /// enforce the program ownership check without this constraint.
    #[account(
        seeds = [b"config"],
        bump,
        seeds::program = alea_program.key(),   // ← MANDATORY. Do not remove.
    )]
    pub alea_config: Account<'info, alea_sdk::Config>,

    /// Transaction payer — passed to Alea's verify instruction.
    pub payer: Signer<'info>,

    /// Clock sysvar for is_round_recent() recency check (MANDATORY).
    pub clock: Sysvar<'info, Clock>,
}

pub fn settle_match(
    ctx: Context<SettleMatch>,
    round: u64,
    signature: [u8; 64],
) -> Result<()> {
    // MANDATORY: reject stale beacons BEFORE verification.
    // Without this, an attacker can replay a known-old beacon to bias outcomes.
    require!(
        alea_sdk::is_round_recent(
            round,
            &ctx.accounts.alea_config,
            &ctx.accounts.clock,
            MAX_BEACON_AGE_SECONDS,
        ),
        YourError::StaleBeacon,
    );

    // MANDATORY: verify the drand beacon via CPI — one line. Returns
    // alea_sdk::VerifiedRandomness (must_use wrapper) so a forgotten
    // `.into_inner()` / `.as_bytes()` produces a compile warning
    // instead of silently dropping the 32 bytes.
    let randomness = 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,
    )?.into_inner();

    // MANDATORY: read the result IMMEDIATELY.
    // Solana return data is overwritten by any subsequent CPI call.
    // Do NOT insert token transfers or other CPIs between verify and this line.
    //
    // Convert the leading 8 bytes to a u64. The try_into unwrap is
    // infallible here: randomness is always [u8; 32] and 0..8 is a valid
    // slice. Do NOT cargo-cult `.unwrap()` into consumer code that reads
    // user input — this is a known-safe path only.
    let random_value = u64::from_le_bytes(randomness[0..8].try_into().unwrap());
    let winner_index = (random_value % 2) as usize;

    // ... settle logic using winner_index against your game's player list ...
    msg!("Alea random_value={} winner_index={}", random_value, winner_index);
    Ok(())
}

The example is annotated rust,ignore because it references a hypothetical YourGameState account type. See programs/example-lottery/ in the repo for a complete, compiling reference consumer.


Security: Mandatory Constraints for Consumer Programs

Two constraints are MANDATORY for any consumer program. Omitting either ships an exploitable program.

1. seeds::program = alea_program.key() on alea_config

#[account(
    seeds = [b"config"],
    bump,
    seeds::program = alea_program.key(),   // ← cannot be omitted
)]
pub alea_config: Account<'info, alea_sdk::Config>,

Without this, an attacker can substitute a fake Config PDA owned by a different program. Anchor re-derives the PDA using Alea's program ID as the signer only when seeds::program is present — it is NOT enforced by default. This guards against total compromise of your randomness source. (ADR 0034)

2. is_round_recent() before trusting randomness

require!(
    alea_sdk::is_round_recent(round, &ctx.accounts.alea_config, &ctx.accounts.clock, 30),
    YourError::StaleBeacon,
);

Without recency enforcement, an attacker can replay an old drand round whose randomness they already know to bias a resolution. Alea's verify accepts any round — recency is the consumer's responsibility.

3. (Privacy-sensitive only) Route through program PDA signer

For applications where the fact that a user is consulting randomness leaks game state (anonymous lotteries, sealed-bid auctions), route the verify CPI through a program-owned PDA signer rather than the end-user wallet. The on-chain BeaconVerified event records the payer. Public-by-design applications can use the end-user wallet directly.


CPI Return Data Ordering Warning

Solana's return data is single-slot — each CPI call overwrites the previous value. Capture the randomness immediately:

// CORRECT — capture first, then other CPIs
let randomness = alea_sdk::cpi::verify(/* args */)?;
token::transfer(transfer_ctx, amount)?;  // safe

// WRONG — overwrites Alea's return data before you read it
token::transfer(transfer_ctx, amount)?;
let randomness = alea_sdk::cpi::verify(/* args */)?;  // stale

Compute Budget Requirement

Every transaction calling Alea MUST include a compute budget instruction of at least 900,000 CU. Solana's default is 200K; Alea's verify needs up to 454K, plus consumer headroom.

// Add this before your instruction in every transaction:
let cu_ix = ComputeBudgetInstruction::set_compute_unit_limit(900_000);

The TypeScript SDK (@alea-drand/sdk) injects this automatically. Rust consumers must add it manually.


Program IDs

Cluster Program ID
Devnet ALEAydzHd4cN2EWcdHKp4hehAE4B88b16gqVtVqsck2U
Mainnet Pending Phase 5 (same vanity ID — cluster binding identical)

Devnet-verified across 10 live rounds. Mainnet traffic begins Phase 5.


Error Codes

Canonical source: programs/alea-verifier/src/errors.rs. CI enforces table-to-enum coherence on every PR (Phase 6).

Code Name Meaning
6000 InvalidSignature BLS pairing check returned non-1 — signature does not attest this (round, pubkey) pair
6001 InvalidG1Point Signature bytes decode but are not on the BN254 G1 curve (y² ≠ x³ + 3 mod p)
6002 RoundZero Round must be > 0 (drand's round 0 is a sentinel, never emitted)
6003 InvalidFieldElement Reserved (unreachable in v1) — do not treat as retryable
6004 NoSquareRoot hash_round_to_g1 exhausted all SVDW candidates; constant corruption or syscall regression (not attacker-reachable)
6005 InvalidG2Point Reserved (unreachable under ADR 0027 fallback path) — do not retry
6006 PairingError alt_bn128_pairing syscall returned Err or wrong-length output (infrastructure)
6007 WrongChainHash Config.chain_hash does not match EXPECTED_EVMNET_CHAIN_HASH (wrong-chain deployment)
6008 WrongPubkey Config.pubkey_g2 does not match EXPECTED_EVMNET_G2_PUBKEY — also emitted by alea_sdk::cpi::verify's owner check on the config account (T1-08, Phase 4.5)
6009 ReturnDataMissing Reserved (unreachable under ADR 0030 Pattern A)
6010 InvalidGenesisTime Config.genesis_time does not match EXPECTED_EVMNET_GENESIS_TIME
6011 InvalidPeriod Config.period does not match EXPECTED_EVMNET_PERIOD
6012 UnauthorizedInit initialize signer does not equal the program's upgrade_authority_address
2001 Anchor ConstraintHasOne update_config signer is not the stored config authority (framework code)
3010 Anchor AccountNotSigner authority account passed without a signature (framework code, fires before 2001)

Troubleshooting

constant_time_eq@0.4.3 requires rustc 1.95 (both cargo check AND cargo build-sbf)

This affects any Rust toolchain older than 1.95, not just BPF. Even on native cargo check / cargo doc with stable rustc 1.94 (the current release at the time of writing), the resolver picks constant_time_eq 0.4.3 which requires rustc 1.95+. The Solana BPF toolchain's embedded rustc is ~1.89-dev so BPF builds hit it too.

Workaround — pin the offending transitive in your own Cargo.toml:

[dependencies]
alea-sdk = "0.1"

# Phase 4.5 fix: pin this transitive to a version that compiles on
# both current stable rustc and Solana BPF toolchain's 1.89-dev:
constant_time_eq = "=0.4.2"

Re-verify after every cargo update — if cargo bumps constant_time_eq past 0.4.2 the pin gets silently overridden unless you use =0.4.2 (exact version, not 0.4.2 which is semver-compatible).

Anchor's declare_id! macro — misleading error cascade on bad base58 length

If you copy the Quick Start and paste a placeholder program ID like MyProg111111111111111111111111111111111, you'll see:

error: pubkey array is not 32 bytes long: len=N
error[E0425]: cannot find value `ID` in crate `crate`
  = help: consider importing this module: `anchor_lang::system_program::ID`

Ignore the rustc helper suggestion about system_program::ID. It's a red herring — rustc can't see that the cascade is caused by the first error (a proc-macro panic inside declare_id!) and guesses at imports. The actual fix is: declare_id! requires exactly a 44-character base58-encoded ed25519 pubkey (32 bytes = 44 base58 chars). Generate a real one with:

solana-keygen new --outfile /tmp/my-program-id.json --no-bip39-passphrase --force
solana-keygen pubkey /tmp/my-program-id.json

This is an Anchor UX issue, not an Alea-specific one, but new consumers hit it on first integration.

See 2026-04-19-solana-bpf-rustc-lag-external-consumers for the full list of commonly-affected transitives (updated as Solana's toolchain evolves).

anchor build fails with E0599: no method named 'source_file'

Anchor 0.30.1's anchor-syn crate calls a proc-macro2 API removed in 1.0.82+. This is an Anchor issue, not an alea-sdk issue, but consumers will hit it. Either:

  • Pin proc-macro2 to <=1.0.81 in your workspace
  • Or use cargo-build-sbf directly + hand-managed IDL (see programs/alea-verifier/ workflow)

Compute budget exceeded / "Program failed to complete"

Every tx that CPIs into Alea MUST include ComputeBudgetInstruction::set_compute_unit_limit(900_000). Solana's default is 200K; Alea needs up to 454K, plus consumer headroom. The @alea-drand/sdk TypeScript SDK injects this automatically; Rust consumers building raw txs must add it manually.

AleaError::WrongPubkey (6008) when the on-chain Config looks correct

This can come from two code paths:

  1. On-chain verify handler — Config.pubkey_g2 != EXPECTED_EVMNET_G2_PUBKEY (wrong-chain init); redeploy with the correct chain_hash / pubkey_g2
  2. alea_sdk::cpi::verify helper — the supplied config account's owner is not alea_sdk::PROGRAM_ID (defense-in-depth check added Phase 4.5 T1-08 for non-Anchor callers)

If you're an Anchor program user with #[account(seeds = [b"config"], bump, seeds::program = alea_program.key())] on your config, the second case is not reachable for you — the PDA re-derivation catches it first.


Links