Common Pitfalls
The footguns below bit us during integration and shipped-SDK development. Anchor 0.30.1 stopped working cleanly with modern web3.js mid-build. constant_time_eq pushed its MSRV past Solana’s bundled rustc. The pairing syscall blows past the 200K CU default. Helius indexers lie about confirmation for a few seconds. None of these are Alea bugs, but every one of them will cost you an afternoon if you hit it cold.
Decision tree — verify reverts with error N
Section titled “Decision tree — verify reverts with error N”Paste the error code from your transaction logs into this tree:
- 6000
InvalidSignature— the signature fails pairing. Almost always: wrong signature bytes for the round, OR fetched from a different drand chain. CheckfetchBeacon(round)is hitting the evmnet chain (default). Don’t mix mainnet drand beacons (BLS12-381) with Alea (BN254). - 6001
InvalidG1Point— the signature isn’t a valid G1 point. Check it’s 64 bytes uncompressed(x || y)big-endian, not a 48-byte compressed form or a 96-byte G2 signature. - 6002
RoundZero— you passed round 0. Drand rounds start at 1. - 6003
InvalidFieldElement/ 6004NoSquareRoot/ 6005InvalidG2Point/ 6006PairingError— infrastructure-level errors. Should not happen for drand-issued beacons. If you see these in prod with a genuine drand signature, file an issue with the round number. - 6007
WrongChainHash— you’re passing a signature from a different drand chain.DRAND_CHAIN_HASHin the SDK is evmnet. Switch to evmnet or stop. - 6008
WrongPubkey— the group public key used for signing doesn’t matchConfig.pubkey_g2. Indicates Alea’s Config has been reinitialized with a wrong key (should not happen without a governance failure) OR you’re signing against a non-drand BLS key. - 6009
ReturnDataMissing— TypeScript-SDK-only. Thrown whengetTransaction().meta.returnDatais absent after a successful send, usually due to indexer lag or a stripped RPC response. Not emitted by the on-chain program. Return-data ordering footgun is still real on the Rust side — readcpi::verify’s result into a local BEFORE any subsequent CPI — but it manifests there as wrong bytes, not this error code. - Anchor wrapper codes (2000-series) —
ConstraintSeedsusually meansseeds::program = alea_program.key()is missing or wrong;AccountNotInitializedmeans the Config PDA doesn’t exist on the cluster you’re connected to.
For a Cmd+F lookup table, see Errors reference.
1. Anchor 0.30.1 + web3.js ≥ 1.98 — .rpc() destroys error codes
Section titled “1. Anchor 0.30.1 + web3.js ≥ 1.98 — .rpc() destroys error codes”Calling program.methods.verify(...).rpc() (the convenient Anchor method) throws Error: Unknown action 'undefined' against any web3.js version 1.98 or later. Every error code is destroyed along the way.
Anchor’s AnchorProvider.sendAndConfirm constructs new SendTransactionError(message, logs) using the old positional 2-argument signature. web3.js 1.98 changed that constructor to object destructuring ({action, signature, transactionMessage, logs}), so passing a string falls through every switch branch and lands at the default "Unknown action 'undefined'". The actual on-chain error code is lost.
The SDK already works around this. If you’re rolling your own: build via .transaction(), sign manually, send with connection.sendRawTransaction({skipPreflight: true}), and read errors from getTransaction().meta.err (raw Solana {InstructionError: [_, {Custom: N}]} shape).
If you wrap Alea in your own Anchor CPI from another program, this doesn’t apply. CPI from an on-chain program is unaffected by Anchor’s .rpc() wrapper.
2. Solana BPF rustc lag — constant_time_eq MSRV
Section titled “2. Solana BPF rustc lag — constant_time_eq MSRV”Building a fresh project that depends on alea-sdk with cargo build-sbf may fail:
error: package `constant_time_eq v0.4.3` cannot be built because it requires rustc 1.95.0 or newerSolana’s cargo-build-sbf ships with an embedded rustc around 1.89-dev. The transitive dep constant_time_eq bumped its MSRV to 1.95 in 0.4.3. Public Rust is several minor versions ahead; Solana’s BPF toolchain is behind.
Pin the transitive dep in your project’s Cargo.toml:
[dependencies]constant_time_eq = "=0.4.2"Until Solana’s BPF rustc catches up (no ETA), every BPF consumer of any crate depending on constant_time_eq needs this pin, not just Alea.
3. Compute budget — set 900K, not the default 200K
Section titled “3. Compute budget — set 900K, not the default 200K”Alea’s verify is ~407K CU. The Solana default per-instruction limit is 200K. A naive program.methods.verify(...).rpc() (or any send without explicit CU budget) fails with Computational budget exceeded.
Add ComputeBudgetProgram.setComputeUnitLimit({ units: 900_000 }) as the first instruction in your transaction. The SDK’s verifyDrandBeacon injects this automatically; if you’re building the transaction yourself, do it explicitly.
import { ComputeBudgetProgram, Transaction } from "@solana/web3.js";
const tx = new Transaction() .add(ComputeBudgetProgram.setComputeUnitLimit({ units: 900_000 })) .add(verifyInstruction);900K leaves room for state writes and a CPI or two. Heavy settle with multiple transfers? Bump to 1,200K.
4. Helius indexer lag — getTransaction can return null for seconds
Section titled “4. Helius indexer lag — getTransaction can return null for seconds”After sendRawTransaction returns and confirmTransaction resolves, calling getTransaction(sig, { commitment: "confirmed" }) on Helius RPC sometimes returns null for 2 to 5 seconds. The transaction is on-chain; the indexer hasn’t caught up.
Retry getTransaction in a loop. The SDK does up to 15 retries with 1-second sleeps. If you’re building your own confirmation, do the same:
let info = null;for (let attempt = 0; attempt < 15; attempt++) { info = await connection.getTransaction(sig, { commitment: "confirmed", maxSupportedTransactionVersion: 0, }); if (info) break; await new Promise((r) => setTimeout(r, 1000));}if (!info) throw new Error("getTransaction never returned");Not Helius-specific. Every RPC indexer lags under load. 15 retries covers the worst we’ve seen on devnet.
5. Skip preflight — error format changes
Section titled “5. Skip preflight — error format changes”By default, sendRawTransaction runs Solana preflight simulation client-side and Anchor wraps the resulting failure in its own error type. With {skipPreflight: true} (required for Alea verifies — see §3), failures come back as raw Solana InstructionError instead.
The shape difference:
// With preflight (default):{ name: "AnchorError", errorCode: { number: 6000, name: "InvalidSignature" },}
// With skipPreflight: true:// (send call doesn't throw an Anchor error — the failure lives on tx confirmation)info.meta.err = { InstructionError: [0, { Custom: 6000 }] }Always read post-send errors from getTransaction().meta.err. Parse for the {InstructionError: [ixIdx, {Custom: N}]} shape and map the custom code to the SDK’s ERRORS map. The SDK does this internally; if you’re sending custom transactions, replicate the pattern.
6. Signature byte order — 64 uncompressed bytes, not 65, not compressed
Section titled “6. Signature byte order — 64 uncompressed bytes, not 65, not compressed”Drand publishes BLS signatures as 64 bytes: a single G1 elliptic-curve point in uncompressed (x || y) format, big-endian. Some BLS libraries default to 96-byte uncompressed G2 or 48-byte compressed G1. Alea expects 64 uncompressed G1 bytes.
When fetching from api.drand.sh/<chain-hash>/public/<round> you get hex; decode to bytes; the result is 64 bytes. The SDK’s fetchBeacon does this for you. If you’re parsing signatures from another source, validate signature.length === 64 before passing to the SDK. Catch it upstream, or eat a 6001 InvalidG1Point from the on-chain program.
7. evmnet vs mainnet drand chains
Section titled “7. evmnet vs mainnet drand chains”Drand publishes several chains in parallel: mainnet (BLS12-381), evmnet (BN254), fastnet, quicknet. Alea verifies evmnet only. Signatures from other chains will not verify against Alea’s configured group public key and will revert with 6007 WrongChainHash or 6000 InvalidSignature depending on which check fails first.
Default endpoint. https://api.drand.sh/public/latest used to default to mainnet (not evmnet). Use the explicit chain hash to be safe:
https://api.drand.sh/04f1e9062b8a81f848fded9c12306733282b2727ecced50032187751166ec8c3/public/latestThe chain hash is exported as DRAND_CHAIN_HASH in the TypeScript SDK and embedded in Config.chain_hash on-chain. fetchBeacon uses the evmnet chain by default.
8. Fake Config substitution — omitting seeds::program ships an exploitable program
Section titled “8. Fake Config substitution — omitting seeds::program ships an exploitable program”If your consumer program embeds the two Alea Accounts fields directly (not the SDK’s reference struct) and forgets the seeds::program = alea_program.key() constraint, an attacker can pass a Config-shaped account owned by a program they control, feed attacker-chosen public keys into the pairing, and make Alea “verify” anything they want. This is full compromise of your randomness consumer.
Alea cannot guard against this from its own side — the Config account is passed in by the caller and the on-chain program trusts whatever it gets. The Rust SDK’s AleaVerify reference struct includes the constraint; if you hand-copy the two fields, include it manually:
#[account( seeds = [b"config"], bump = alea_config.bump, seeds::program = alea_program.key(), // ← DO NOT OMIT)]pub alea_config: Account<'info, alea_sdk::Config>,Any consumer program that ships without this constraint is exploitable. Grep your Accounts structs before deploy.
9. Round freshness — Alea won’t reject stale rounds
Section titled “9. Round freshness — Alea won’t reject stale rounds”Alea verifies that a signature is mathematically valid for a given round. It does NOT check whether that round is recent. An adversary can submit a valid drand round from last year and Alea will happily verify it.
Use the Rust SDK’s is_round_recent(round, config, clock, max_age_seconds) helper as a require!() at the start of your instruction:
require!( alea_sdk::is_round_recent( round, &ctx.accounts.alea_config, &ctx.accounts.clock, 30, ), MyError::StaleBeacon,);30s is the default. 3s (one drand round) if you’re MEV-adjacent. Skip the check and you have zero replay defense — Alea won’t save you there.
Related
Section titled “Related”- Architecture — why the primitives are shaped this way
- Security model — what Alea defends against vs not
- Rust SDK and TypeScript SDK — full API references
- Errors reference — error code table