Skip to main content
This guide covers how to integrate Range’s Onchain Risk Verifier (ORV) into a Solana program. The ORV brings real-world risk intelligence onchain - allowing programs to reject high-risk or sanctioned addresses at the protocol level using cryptographically signed, tamper-proof data.
Prerequisites: You need a Risk API key, a Solana development environment (Anchor or Pinocchio), and familiarity with Switchboard On-Demand Oracles.

How It Works

The Onchain Risk Verifier uses Switchboard On-Demand Oracles to fetch signed risk data from Range’s Risk API and verify it inside your Solana program. No persistent data feeds are required - quotes are requested on demand.

Security Model

  • Tamper-proof: The program reconstructs the exact same feed definition onchain and hashes it. Any change to the URL, headers, or processing tasks changes the hash, causing verification to fail.
  • Cryptographic verification: Switchboard oracles run in Trusted Execution Environments (TEEs) and sign results with Ed25519. The program verifies these signatures Onchain.
  • Freshness checks: The program rejects quotes older than a configurable number of slots (e.g., 50 slots ≈ 20 seconds).
  • API keys stay off-chain: Range API keys are injected as variable overrides at the oracle level - they never appear Onchain.

Use Cases

ScenarioHow ORV Helps
Privacy protocolsScreen deposits to prevent illicit funds from entering the pool
Lending protocolsReject deposits or borrows from high-risk wallets
Treasury managementGate payouts to wallets below a risk threshold
GovernanceOnly allow proposals or votes from verified-clean addresses
BridgesBlock transfers involving sanctioned or malicious addresses
DEX / AMMWarn or block swaps with flagged counterparties

Step 1: Define the Oracle Job (TypeScript)

The Oracle Job tells Switchboard what data to fetch and how to process it. This job calls Range’s Address Risk Score endpoint, parses the riskScore, scales it to 0–100, and bounds the result.
import { OracleJob } from '@switchboard-xyz/common';

function getRangeRiskScoreJob(address: string): OracleJob {
  return OracleJob.fromObject({
    tasks: [
      {
        httpTask: {
          url: `https://api.range.org/v1/risk/address?address=${address}&network=solana`,
          headers: [
            { key: 'accept', value: 'application/json' },
            // Resolved by Switchboard oracle via variable override - never Onchain
            { key: 'X-API-KEY', value: '${RANGE_API_KEY}' },
          ],
        },
      },
      // Extract the numeric risk score from the response
      { jsonParseTask: { path: '$.riskScore' } },
      // Scale from 0–10 to 0–100 for integer precision Onchain
      { multiplyTask: { scalar: 10 } },
      // Bound the result
      {
        boundTask: {
          lowerBoundValue: '0',
          onExceedsLowerBoundValue: '0',
          upperBoundValue: '100',
          onExceedsUpperBoundValue: '100',
        },
      },
    ],
  });
}
The address is embedded directly in the Oracle Job URL. This is intentional — the Switchboard feed ID is a deterministic hash of the entire feed definition (including the URL). Your onchain program reconstructs the same URL using the query_account pubkey and hashes it to verify the oracle returned data for the correct address. A new Oracle Job must be created for each address you want to check.
The ${RANGE_API_KEY} placeholder is resolved by the Switchboard oracle at runtime using variable overrides. Your API key is never exposed onchain or in transaction data.

Step 2: Request a Signed Quote (TypeScript)

Use the Switchboard SDK to request a signed quote and build the Ed25519 verification instruction.
import { PublicKey, Keypair, TransactionInstruction } from '@solana/web3.js';
import { CrossbarClient, IOracleFeed } from '@switchboard-xyz/common';
import * as sb from '@switchboard-xyz/on-demand';
import { getDefaultQueue } from '@switchboard-xyz/on-demand';

async function getOracleQuote(
  payer: Keypair,
  addressToCheck: string,
): Promise<{
  queueAccount: PublicKey;
  sigVerifyIx: TransactionInstruction;
}> {
  const { gateway, rpcUrl } = await sb.AnchorUtils.loadEnv();

  // Use devnet queue for testing, getDefaultQueue(rpcUrl) for mainnet
  const queue = await sb.getDefaultDevnetQueue(rpcUrl);
  const crossbar = CrossbarClient.default();

  // Build the feed specification with the target address baked in
  const feed: IOracleFeed = {
    name: 'Risk Score',
    jobs: [getRangeRiskScoreJob(addressToCheck)],
    minJobResponses: 1,
    minOracleSamples: 1,
    maxJobRangePct: 100,
  };

  // Request a signed quote from the oracle network
  const sigVerifyIx = await queue.fetchQuoteIx(crossbar, [feed], {
    variableOverrides: { RANGE_API_KEY: process.env.RANGE_API_KEY! },
    numSignatures: 1,
    instructionIdx: 0, // Ed25519 verify must be at this index in the transaction
  });

  return { queueAccount: queue.pubkey, sigVerifyIx };
}

Step 3: Build and Send the Transaction (TypeScript)

Combine the signature verification instruction with your program instruction in a single transaction.
The Switchboard SDK (@switchboard-xyz/on-demand) uses @solana/web3.js types internally. The transaction building code below uses web3.js for compatibility with the SDK.
import {
  Connection,
  Transaction,
  sendAndConfirmTransaction,
  SYSVAR_CLOCK_PUBKEY,
  SYSVAR_SLOT_HASHES_PUBKEY,
  SYSVAR_INSTRUCTIONS_PUBKEY,
  PublicKey,
  Keypair,
  TransactionInstruction,
} from '@solana/web3.js';
import { createHash } from 'crypto';

// 8-byte Anchor discriminator for the instruction
const VERIFY_IX = createHash('sha256')
  .update('global:verify_risk_score_feed')
  .digest()
  .subarray(0, 8);

function buildProgramIx(
  programId: PublicKey,
  queue: PublicKey,
  queryAccount: PublicKey,
): TransactionInstruction {
  return new TransactionInstruction({
    programId,
    keys: [
      { pubkey: queue, isSigner: false, isWritable: false },
      { pubkey: SYSVAR_CLOCK_PUBKEY, isSigner: false, isWritable: false },
      { pubkey: SYSVAR_SLOT_HASHES_PUBKEY, isSigner: false, isWritable: false },
      { pubkey: SYSVAR_INSTRUCTIONS_PUBKEY, isSigner: false, isWritable: false },
      { pubkey: queryAccount, isSigner: false, isWritable: false },
    ],
    data: VERIFY_IX,
  });
}

// Full transaction flow
async function verifyRiskScore(
  connection: Connection,
  payer: Keypair,
  programId: PublicKey,
  addressToCheck: PublicKey,
) {
  // Get the signed oracle quote for the target address
  const { queueAccount, sigVerifyIx } = await getOracleQuote(
    payer,
    addressToCheck.toBase58(),
  );

  // Build the program instruction
  const programIx = buildProgramIx(programId, queueAccount, addressToCheck);

  // Transaction: [sigVerify at index 0, program ix at index 1]
  const tx = new Transaction().add(sigVerifyIx, programIx);
  tx.feePayer = payer.publicKey;
  tx.recentBlockhash = (
    await connection.getLatestBlockhash('confirmed')
  ).blockhash;

  const signature = await sendAndConfirmTransaction(connection, tx, [payer]);
  console.log('Risk score verified onchain. Tx:', signature);
}

Step 4: Verify Onchain (Rust)

The Solana program reconstructs the same feed definition, hashes it, and compares against the oracle quote to ensure data integrity.
#![allow(deprecated)]
#![allow(unexpected_cfgs)]

use anchor_lang::prelude::*;
use anchor_lang::solana_program::hash::hash;
use prost::Message;
use switchboard_on_demand::{default_queue, QueueAccountData};
use switchboard_on_demand::{Instructions, QuoteVerifier, SlotHashes};
use switchboard_protos::oracle_job::oracle_job::http_task::Header;
use switchboard_protos::oracle_job::oracle_job::multiply_task;
use switchboard_protos::oracle_job::oracle_job::task;
use switchboard_protos::oracle_job::oracle_job::{
    BoundTask, HttpTask, JsonParseTask, MultiplyTask, Task,
};
use switchboard_protos::{OracleFeed, OracleJob};

declare_id!("YOUR_PROGRAM_ID");

#[program]
pub mod risk_oracle {
    use super::*;

    pub fn verify_risk_score_feed(ctx: Context<VerifyRiskScoreFeed>) -> Result<()> {
        let mut verifier = QuoteVerifier::new();
        let slot = Clock::get()?.slot;

        // Configure the verifier with required sysvars
        verifier
            .queue(ctx.accounts.queue.as_ref())
            .slothash_sysvar(ctx.accounts.slothashes.as_ref())
            .ix_sysvar(ctx.accounts.instructions.as_ref())
            .clock_slot(slot);

        // Verify the Ed25519 signature at instruction index 0
        let quote = verifier.verify_instruction_at(0).unwrap();
        let quote_slot = quote.slot();

        // Ensure the quote is fresh (within 50 slots ≈ 20 seconds)
        if slot.saturating_sub(quote_slot) > 50 {
            return Err(ErrorCode::StaleQuote.into());
        }

        // Extract the verified feed data
        let feeds = quote.feeds();
        require!(!feeds.is_empty(), ErrorCode::NoOracleFeeds);
        let feed = &feeds[0];

        // Reconstruct the feed ID onchain and compare
        let expected_feed_id = create_risk_score_feed_id(
            &ctx.accounts.query_account.key()
        )?;
        require!(
            *feed.feed_id() == expected_feed_id,
            ErrorCode::FeedMismatch
        );

        // The risk score is now verified - use it in your program logic
        msg!(
            "Verified risk score feed! Value: {}",
            feed.value().to_string().as_str()
        );

        Ok(())
    }
}

// Account structure for the verify_risk_score_feed instruction
#[derive(Accounts)]
pub struct VerifyRiskScoreFeed<'info> {
    #[account(address = default_queue())]
    pub queue: AccountLoader<'info, QueueAccountData>,
    pub clock: Sysvar<'info, Clock>,
    pub slothashes: Sysvar<'info, SlotHashes>,
    pub instructions: Sysvar<'info, Instructions>,
    /// CHECK: Only the pubkey is needed to reconstruct the feed ID
    pub query_account: UncheckedAccount<'info>,
}

#[error_code]
pub enum ErrorCode {
    #[msg("No oracle feeds available")]
    NoOracleFeeds,
    #[msg("Feed hash mismatch - oracle feed does not match expected configuration")]
    FeedMismatch,
    #[msg("Stale quote - the quote is too old")]
    StaleQuote,
    #[msg("Risk score exceeds allowed threshold")]
    RiskTooHigh,
}

// Helper function to reconstruct the feed ID onchain
fn create_risk_score_feed_id(query_pubkey: &Pubkey) -> Result<[u8; 32]> {
    let addr = bs58::encode(query_pubkey).into_string();
    let url = format!(
        "https://api.range.org/v1/risk/address?address={}&network=solana",
        addr
    );

    let feed = OracleFeed {
        name: Some("Risk Score".to_string()),
        jobs: vec![OracleJob {
            tasks: vec![
                Task {
                    task: Some(task::Task::HttpTask(HttpTask {
                        url: Some(url),
                        headers: [
                            Header {
                                key: Some("accept".to_string()),
                                value: Some("application/json".to_string()),
                            },
                            Header {
                                key: Some("X-API-KEY".to_string()),
                                value: Some("${RANGE_API_KEY}".to_string()),
                            },
                        ]
                        .into(),
                        ..Default::default()
                    })),
                },
                Task {
                    task: Some(task::Task::JsonParseTask(JsonParseTask {
                        path: Some("$.riskScore".to_string()),
                        ..Default::default()
                    })),
                },
                Task {
                    task: Some(task::Task::MultiplyTask(MultiplyTask {
                        multiple: Some(multiply_task::Multiple::Scalar(10.0)),
                    })),
                },
                Task {
                    task: Some(task::Task::BoundTask(BoundTask {
                        lower_bound_value: Some("0".into()),
                        upper_bound_value: Some("100".into()),
                        on_exceeds_lower_bound_value: Some("0".into()),
                        on_exceeds_upper_bound_value: Some("100".into()),
                        ..Default::default()
                    })),
                },
            ],
            weight: None,
        }],
        min_job_responses: Some(1),
        min_oracle_samples: Some(1),
        max_job_range_pct: Some(100),
    };

    // Hash the protobuf-encoded feed to get the deterministic feed ID
    let bytes = OracleFeed::encode_length_delimited_to_vec(&feed);
    Ok(hash(&bytes).to_bytes())
}

Step 5: Run the Example

Range provides a complete working example with both Anchor and Pinocchio implementations.
# Clone the example repository
git clone https://github.com/rangesecurity/oracle-example.git
cd oracle-example/anchor/client

# Install dependencies
npm install

# Set your Range API key
export RANGE_API_KEY="your_api_key_here"

# Set the RPC URL (defaults to http://127.0.0.1:8899 if not set)
export RPC_URL="https://api.devnet.solana.com"

# Set the path to your devnet wallet keypair (defaults to ../keypair.json)
export DEV_WALLET_KEYPAIR_PATH="/path/to/your/keypair.json"

# Run the test
npm test
The test requires a funded Solana devnet wallet. If you don’t have one, generate a keypair with solana-keygen new -o keypair.json and airdrop devnet SOL with solana airdrop 2 --keypair keypair.json --url devnet.
Expected output:
Program log: Verified risk score feed! Value: 100
The repository includes both an Anchor and Pinocchio implementation. Both are functionally identical - choose whichever framework your project uses.

Customizing for Your Program

Adjusting the Risk Threshold

Modify the onchain program to enforce your risk policy:
let risk_score = feed.value(); // 0–100 scale (original 0–10 × 10)

// Block wallets with risk score > 7 (70 on the 0–100 scale)
if risk_score > 70.into() {
    return Err(ErrorCode::RiskTooHigh.into());
}

Checking Different Networks

Update the URL in both the TypeScript OracleJob and the Rust feed reconstruction to query different networks:
let url = format!(
    "https://api.range.org/v1/risk/address?address={}&network={}",
    addr, network
);
The feed definition must be identical in both the client-side OracleJob and the onchain feed reconstruction. Any difference - even in field ordering or default values - will change the hash and cause verification to fail.

Production Considerations

  • Increase numSignatures - Use more than 1 signature for production to increase consensus requirements.
  • Tune staleness - The example uses 50 slots (~20 seconds). Tighten this for time-sensitive operations.
  • Use mainnet queue - Switch from getDefaultDevnetQueue to getDefaultQueue for production deployments.
  • Handle errors gracefully - Decide whether a failed risk check should block the transaction or allow it with a warning flag.

Cross-Chain Support

The Onchain Risk Verifier is currently implemented for Solana. The same architecture could be adapted for EVM (Solidity) and CosmWasm smart contracts — the core pattern of oracle-fetched signed data with onchain signature and feed integrity verification is framework-agnostic. If you’re interested in an EVM or CosmWasm implementation, get in touch.

What’s Next

Last modified on March 2, 2026