Verify wallet risk directly inside Solana programs using backend-signed Ed25519 attestations — no oracles required.
This guide covers how to integrate Range’s SigVerify pattern into a Solana
program. SigVerify is a lightweight alternative to the
Onchain Risk Verifier — instead of
relying on oracle networks, your backend calls the Range API, signs an
attestation, and your onchain program verifies the signature directly using
Ed25519.
Prerequisites: You need a Risk API key, a
Solana development environment (Anchor), and a backend service that holds an
Ed25519 keypair.
Your backend screens the user’s wallet via the Range API, then signs a
time-stamped attestation message. The user submits this attestation in a Solana
transaction, and the onchain program verifies the signature, signer identity,
and freshness — all without any oracle dependency.
Ed25519 signature verification: The onchain program verifies the backend’s
signature using Ed25519. This example uses the
brine-ed25519 crate for in-program
verification. Alternatively, you can use Solana’s native
Ed25519 SigVerify precompile
— this requires an additional instruction and onchain introspection to
validate the signature (similar to what Switchboard does under the hood), but
avoids depending on a third-party crate. Either way, forged attestations are
rejected.
Signer binding: The message embeds the user’s pubkey. The program verifies
it matches the transaction signer, preventing replay attacks.
Freshness check: Attestations expire after 60 seconds (configurable). The
program compares the message timestamp against the onchain clock.
Audit trail: Each attestation includes a unique riskCallId that links
the onchain verification to the original API call.
API keys stay off-chain: The Range API key is only used by your backend —
it never appears in transaction data.
Your backend calls the Range API to get the wallet’s risk score and generates a
unique riskCallId for audit tracking.
Copy
import { randomBytes } from 'crypto';interface RiskScreenResult { score: number; level: string; reasoning: string; riskCallId: string;}async function screenRecipient( userAddress: string, apiKey: string, network: string = 'solana',): Promise<RiskScreenResult> { const params = new URLSearchParams({ address: userAddress, network }); const response = await fetch( `https://api.range.org/v1/risk/address?${params}`, { headers: { Authorization: `Bearer ${apiKey}` } }, ); if (!response.ok) { throw new Error( `Range API error: ${response.status} ${response.statusText}`, ); } const data = await response.json(); // Unique identifier that ties the onchain attestation // back to this specific API call for compliance tracking const riskCallId = randomBytes(16).toString('hex'); return { score: data.riskScore, level: data.riskLevel, reasoning: data.reasoning, riskCallId, };}
The riskCallId is generated client-side as a random 16-byte hex string. This
ties the onchain attestation to a specific screening event for auditability.
The message format is {timestamp}_{pubkey}_{riskCallId} where:
timestamp — Unix timestamp in seconds (used for freshness check onchain)
pubkey — The user’s Solana wallet address (base58)
riskCallId — Unique identifier linking to the API screening result
The backend secret key must be kept secure. If compromised, an attacker could
forge attestations. Rotate the backend keypair and update the onchain
program’s BACKEND_PUBKEY constant if a key is ever leaked.
Step 3: Build and Send the Transaction (TypeScript)
The user submits the backend’s attestation in a Solana transaction. The SDK’s
buildVerifyRiskInstruction constructs the instruction with the Anchor
discriminator, signature, and message — see the
full implementation in the example repo.
Copy
async function verifyRiskOnchain( rpc: ReturnType<typeof createSolanaRpc>, rpcSubscriptions: ReturnType<typeof createSolanaRpcSubscriptions>, userSigner: TransactionSigner, backendSecretKey: Uint8Array, apiKey: string,) { // 1. Screen the wallet const riskResult = await screenRecipient(userSigner.address, apiKey); console.log(`Risk score: ${riskResult.score}, Level: ${riskResult.level}`); if (riskResult.score >= 10) { throw new Error('Risk score too high, blocking access.'); } // 2. Backend signs the attestation const timestamp = Math.floor(Date.now() / 1000); const { signature, message } = createSignedMessage( timestamp, userSigner.address, riskResult.riskCallId, backendSecretKey, ); // 3. Build the instruction const instruction = buildVerifyRiskInstruction( userSigner, signature, message, ); // 4. Build, sign, and send the transaction const { value: latestBlockhash } = await rpc.getLatestBlockhash().send(); const transactionMessage = pipe( createTransactionMessage({ version: 0 }), m => setTransactionMessageFeePayerSigner(userSigner, m), m => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, m), m => appendTransactionMessageInstruction(instruction, m), ); const signedTransaction = await signTransactionMessageWithSigners(transactionMessage); assertIsTransactionWithBlockhashLifetime(signedTransaction); const sendAndConfirm = sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions, }); await sendAndConfirm(signedTransaction, { commitment: 'confirmed' }); const txSignature = getSignatureFromTransaction(signedTransaction); console.log('Risk attestation verified onchain. Tx:', txSignature);}
impl<'info> VeriFyRiskAccounts<'info> { pub fn verify(&self, args: VerifyRangeArgs) -> Result<()> { let user_key = self.signer.key(); let signature = args.signature; let message = args.message; // 1. Verify the Ed25519 signature against the backend's public key let backend_pubkey = Pubkey::from_str_const(BACKEND_PUBKEY); ScoreVerifier::sig_verify( &backend_pubkey.to_bytes(), &signature, &message, )?; // 2. Parse the message into its components let ExtractedMessage { timestamp, riskscore_pubkey, risk_call_id, } = ScoreVerifier::extract_message(&message)?; // 3. Verify the message pubkey matches the transaction signer require_keys_eq!(user_key, riskscore_pubkey, CustomErrorCode::SignersMismatch); // 4. Verify the attestation is fresh (within MAX_SCORE_LIFETIME seconds) let clock = Clock::get()?; let current_timestamp = clock.unix_timestamp as u64; let time_diff = current_timestamp.saturating_sub(timestamp); require!( time_diff <= MAX_SCORE_LIFETIME, CustomErrorCode::ScoreLifetimeExpired ); msg!("Signature verified successfully for risk call ID: {}", risk_call_id); msg!("User can now be granted access to the protocol."); Ok(()) }}
# Clone the example repositorygit clone https://github.com/rangesecurity/sigverify-example.gitcd sigverify-example/sigverify# Install dependenciesyarn install# Generate your own backend and user keypairssolana-keygen new --outfile app/backend_keypair.json --no-bip39-passphrasesolana-keygen new --outfile app/user_keypair.json --no-bip39-passphrase
Update BACKEND_PUBKEY in programs/sigverify/src/constants.rs with your
backend’s public key:
Since you’re deploying your own copy of the program, you’ll also need to
generate a new program keypair and update the program ID:
Copy
# Generate a new program keypairsolana-keygen new --outfile target/deploy/sigverify-keypair.json --no-bip39-passphrase# Get the new program IDsolana-keygen pubkey target/deploy/sigverify-keypair.json
Update the program ID in both programs/sigverify/src/lib.rs (declare_id!)
and Anchor.toml ([programs.devnet]) with the output, and update
PROGRAM_ID in app/sdk.ts to match.
Copy
# Set your Range API keyecho "RANGE_API_KEY=your_api_key_here" > .env# Build and deployanchor buildanchor deploy --provider.cluster devnet# Fund the user walletsolana airdrop 2 $(solana-keygen pubkey app/user_keypair.json) --url devnet# Run the examplecd app && npx tsx index.ts
// Allow attestations up to 5 minutes oldpub const MAX_SCORE_LIFETIME: u64 = 300;
Longer lifetimes reduce the number of API calls but increase the window for
stale risk data. For high-value operations, keep the lifetime short (60
seconds or less).