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.
How It Works
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.
How It Compares to the Oracle Approach
| SigVerify | Onchain Risk Verifier |
|---|
| Trust model | Trust your backend signer | Trust Switchboard TEE oracles |
| Dependencies | None (Ed25519 only) | Switchboard SDK + oracle network |
| Latency | Single API call + sign | Oracle request + quote fetch |
| Cost | ~40k CUs | ~20k Cus |
| Best for | Apps with an existing backend | Fully decentralized protocols |
Security Model
- 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.
Use Cases
| Scenario | How SigVerify Helps |
|---|
| Privacy protocols | Screen deposits to prevent illicit funds from entering the pool |
| Token launches | Gate participation to wallets below a risk threshold |
| Airdrops | Screen recipients before distributing tokens |
| DAO treasuries | Require risk attestation before withdrawals |
| NFT mints | Block mints from sanctioned or malicious wallets |
| DeFi protocols | Require fresh risk screening before deposits |
| Payment rails | Verify sender/recipient risk before processing transfers |
Step 1: Screen the Wallet (TypeScript)
Your backend calls the Range API to get the wallet’s risk score and generates a
unique riskCallId for audit tracking.
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.
Step 2: Sign the Attestation (TypeScript)
After screening, your backend signs a message that binds the risk result to the
user’s wallet and a specific point in time.
import nacl from 'tweetnacl';
function createSignedMessage(
timestamp: number,
userAddress: string,
riskCallId: string,
backendSecretKey: Uint8Array,
): { signature: Uint8Array; message: Uint8Array } {
// Message format: "{timestamp}_{pubkey}_{riskCallId}"
const messageStr = `${timestamp}_${userAddress}_${riskCallId}`;
const message = new TextEncoder().encode(messageStr);
const signature = nacl.sign.detached(message, backendSecretKey);
return {
signature: new Uint8Array(signature),
message: new Uint8Array(message),
};
}
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.
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);
}
Step 4: Verify Onchain (Rust)
The Anchor program verifies the Ed25519 signature, checks that the message
signer matches the transaction signer, and enforces a freshness window.
Program Entry Point
use anchor_lang::prelude::*;
declare_id!("YOUR_PROGRAM_ID");
#[program]
pub mod sigverify {
use super::*;
pub fn verify_risk_signature(
ctx: Context<VeriFyRiskAccounts>,
args: VerifyRangeArgs,
) -> Result<()> {
ctx.accounts.verify(args)
}
}
#[derive(AnchorSerialize, AnchorDeserialize)]
pub struct VerifyRangeArgs {
pub signature: Vec<u8>, // 64-byte Ed25519 detached signature
pub message: Vec<u8>, // UTF-8: "{timestamp}_{pubkey}_{riskCallId}"
}
#[derive(Accounts)]
pub struct VeriFyRiskAccounts<'info> {
#[account(mut)]
pub signer: Signer<'info>,
}
Verification Logic
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(())
}
}
Signature Verification and Message Parsing
use brine_ed25519::sig_verify;
pub struct ExtractedMessage {
pub timestamp: u64,
pub riskscore_pubkey: Pubkey,
pub risk_call_id: String,
}
pub struct ScoreVerifier;
impl ScoreVerifier {
/// Verify an Ed25519 signature against the backend's public key
pub fn sig_verify(pubkey: &[u8], sig: &[u8], message: &[u8]) -> Result<()> {
sig_verify(pubkey, sig, message)
.map_err(|_| CustomErrorCode::CouldntVerifySignature)?;
Ok(())
}
/// Parse the attestation message: "{timestamp}_{pubkey}_{riskCallId}"
pub fn extract_message(message_bytes: &[u8]) -> Result<ExtractedMessage> {
let message_string = String::from_utf8_lossy(message_bytes);
let parts: Vec<&str> = message_string.split('_').collect();
require_eq!(parts.len(), 3, CustomErrorCode::WrongMessageSplitLength);
let timestamp = parts[0]
.parse::<u64>()
.map_err(|_| CustomErrorCode::TimestampParsingFailed)?;
let riskscore_pubkey = Pubkey::from_str(parts[1])
.map_err(|_| CustomErrorCode::PubkeyParsingFailed)?;
let risk_call_id = parts[2].to_string();
Ok(ExtractedMessage {
timestamp,
riskscore_pubkey,
risk_call_id,
})
}
}
Step 5: Run the Example
Clone the
example repository and
set up your own keys.
# Clone the example repository
git clone https://github.com/rangesecurity/sigverify-example.git
cd sigverify-example/sigverify
# Install dependencies
yarn install
# Generate your own backend and user keypairs
solana-keygen new --outfile app/backend_keypair.json --no-bip39-passphrase
solana-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:
solana-keygen pubkey app/backend_keypair.json
// programs/sigverify/src/constants.rs
pub const BACKEND_PUBKEY: &str = "<your-backend-pubkey>";
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:
# Generate a new program keypair
solana-keygen new --outfile target/deploy/sigverify-keypair.json --no-bip39-passphrase
# Get the new program ID
solana-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.
# Set your Range API key
echo "RANGE_API_KEY=your_api_key_here" > .env
# Build and deploy
anchor build
anchor deploy --provider.cluster devnet
# Fund the user wallet
solana airdrop 2 $(solana-keygen pubkey app/user_keypair.json) --url devnet
# Run the example
cd app && npx tsx index.ts
Expected output:
Wallet: <your-address> Risk score: 0 Level: Very low risk
Transaction confirmed: <tx-signature>
For local development, you can use Surfpool to
run a local Solana validator that auto-deploys your program on file changes.
Customizing for Your Program
Adjusting the Risk Threshold
Modify the screening threshold in your backend to match your risk policy:
// Block wallets with risk score >= 7 (out of 10)
if (riskResult.score >= 7) {
throw new Error('Risk score too high, blocking access.');
}
Adjusting the Attestation Lifetime
Update the constant in your onchain program:
// Allow attestations up to 5 minutes old
pub 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).
Adding the Risk Score Onchain
You can extend the message format to include the risk score itself, allowing
your program to enforce thresholds onchain:
// Extended message format: "{timestamp}_{pubkey}_{riskCallId}_{riskScore}"
// Then in your verification logic:
if risk_score > MAX_ALLOWED_RISK_SCORE {
return Err(CustomErrorCode::RiskTooHigh.into());
}
Production Considerations
- Rotate backend keys — Have a key rotation strategy. Update
BACKEND_PUBKEY in your program via an upgrade or a config PDA.
- Use HTTPS everywhere — Ensure your backend-to-Range-API communication uses
TLS.
- Log
riskCallId — Store the mapping between riskCallId and screening
results for compliance audits.
What’s Next