Skip to main content
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

SigVerifyOnchain Risk Verifier
Trust modelTrust your backend signerTrust Switchboard TEE oracles
DependenciesNone (Ed25519 only)Switchboard SDK + oracle network
LatencySingle API call + signOracle request + quote fetch
Cost~40k CUs~20k Cus
Best forApps with an existing backendFully 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

ScenarioHow SigVerify Helps
Privacy protocolsScreen deposits to prevent illicit funds from entering the pool
Token launchesGate participation to wallets below a risk threshold
AirdropsScreen recipients before distributing tokens
DAO treasuriesRequire risk attestation before withdrawals
NFT mintsBlock mints from sanctioned or malicious wallets
DeFi protocolsRequire fresh risk screening before deposits
Payment railsVerify 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

Last modified on March 2, 2026