Skip to content

Architecture

The Alea program is one Anchor instruction wrapped around Solana’s alt_bn128_pairing syscall.

caller ──► verify(round, signature)
├─ require round > 0 (else revert 6002 RoundZero)
├─ require signature on G1 (canonical) (else revert 6001 InvalidG1Point)
├─ msg_hash = keccak256(round.to_be_bytes()) [8 bytes BE → 32]
├─ H = map_to_curve_g1(msg_hash) [deterministic SVDW]
├─ alt_bn128_pairing( ← Solana syscall, BN254 optimal Ate
│ [(signature, G2_generator),
│ (negate(H), Config.pubkey_g2)]
│ )
├─ if returns Some(true): set_return_data(sha256(signature)) [32 bytes]
├─ if returns Some(false): revert with AleaError::InvalidSignature (6000)
└─ if returns None (syscall Err): revert with AleaError::PairingError (6006)

If the pairing check returns 1, the signature is a valid BLS aggregate produced by the drand threshold of nodes for that round. If it returns 0, the bytes don’t verify.

The program holds a single Config PDA at seeds [b"config"] containing the drand chain’s group public key, chain hash, genesis time, and period. No per-call state, no user accounts, no balances.

The program has two admin instructions that touch Config — initialize and update_config — plus the read-only verify. initialize writes Config once and is gated to the BPFLoader upgrade authority via AleaError::UnauthorizedInit (6012). update_config is gated by Anchor’s has_one = authority against Config.authority, the init signer. Both admin instructions enforce byte-equality against EXPECTED_EVMNET_* constants at the program level — Config cannot be written with non-evmnet values without first shipping an upgrade that changes those constants. Which requires the upgrade authority.

A few consequences of this shape. Two calls with the same (round, signature) produce identical on-chain effects — a return-data emission and a successful exit — so replay defense is your consumer program’s job (via is_round_recent, below), not Alea’s. The program owns no lamports and makes no transfers; there’s nothing to drain. And a future upgrade can add new instructions but cannot retroactively change what verify(round, signature) returned for inputs that already verified today. The math is fixed the moment a signature passes.

Why is is_round_recent a separate helper instead of baked into verify? Because making it mandatory inside the verifier would force Alea to take a stance on what “recent enough” means — and the honest answer is “it depends.” A lottery needs 30 seconds of slack, an MEV-resistant draw needs 3, a commit-reveal needs no freshness check at all because the round is known in advance. Baking in a single window either tightens it past legitimate use cases or loosens it past adversarial ones. Externalizing the check pushes the decision back to the consumer, which is the only party that can make it.

Measured breakdown from a devnet verify in April 2026:

OpCUNote
alt_bn128_pairing (2 pairs)~250KThe BLS verify itself
map_to_curve_g1 (SVDW)~120KHash-to-curve preimage
Anchor accounts + return data~37KDiscriminator parse, PDA load, sol_set_return_data

Typical measured verify is ~407K. Worst case observed in benchmarks across input variance: ~454K (documented in the SDK’s rustdoc). Set your ComputeBudgetProgram::set_compute_unit_limit to 900,000 whenever you call Alea, whether via CPI from another program or as a standalone tx — 900K covers 454K worst-case plus enough headroom for state writes and a second CPI on top. The TypeScript SDK injects the compute-budget instruction automatically; from Rust, prepend it yourself.

Alea’s security rests on two assumptions:

  1. Drand’s threshold-BLS group public key is correct. If the public key in the Config PDA is wrong — either never-correct from a botched init, or replaced via a hostile upgrade — every signature fails. Detectable at integration time: cross-verified fixtures for drand rounds 1 and 9,337,227 live in the repo, and your CI should run them against the live program on deploy day.

  2. Solana’s alt_bn128_pairing syscall is correctly implemented. The syscall has been live on mainnet-beta for several protocol versions. SIMD-0334 activated in March 2026 was a length-check hardening, not a new feature. A bug in the syscall affects every BN254-using Solana program, not Alea specifically. Blast radius is the network.

Nothing on this list depends on Alea’s deploy keypair or a single human’s intent. The Config PDA is write-gated by the program’s byte-equality guards: non-evmnet values are rejected at the instruction boundary. A malicious upgrade could brick the program — ship a binary that reverts every call, or rewrites Config with wrong constants after changing the EXPECTED_EVMNET_* values — but it cannot forge a signature. A bad signature still fails the pairing check on any deployed binary.

For the governance plan (deployer keypair today, planned multisig and eventual immutability with no fixed timeline), see the Security model page — both upgrade authority and Config.authority are covered there.

The on-chain account at devnet address 6anALRxD98Tw7zbA9d5i4NJfTvxDsNBHohHVJWxv2Xm8, derived from [b"config"] seeded against the Alea program. Declaration order matches Borsh serialization order:

pub struct Config {
pub pubkey_g2: [u8; 128], // drand group public key, uncompressed G2
pub genesis_time: u64, // drand chain genesis, Unix seconds
pub period: u64, // beacon cadence, seconds (3 for evmnet)
pub chain_hash: [u8; 32], // drand evmnet chain hash
pub authority: Pubkey, // admin key gating update_config
pub bump: u8, // canonical PDA bump
}

Consumer programs reference it read-only. The seeds::program = alea_program.key() constraint on your Accounts struct re-derives the PDA under Alea’s program ID, so a Config account owned by a different program cannot be substituted. Omitting that constraint ships a program with a full-compromise bug — see Security model for the fake-Config scenario.