Skip to content

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. Check fetchBeacon(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 / 6004 NoSquareRoot / 6005 InvalidG2Point / 6006 PairingError — 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_HASH in the SDK is evmnet. Switch to evmnet or stop.
  • 6008 WrongPubkey — the group public key used for signing doesn’t match Config.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 when getTransaction().meta.returnData is 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 — read cpi::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)ConstraintSeeds usually means seeds::program = alea_program.key() is missing or wrong; AccountNotInitialized means 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 newer

Solana’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.

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/latest

The 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.