Skip to main content
This guide covers how to use Range’s Transaction Simulator to validate Solana transactions before they hit the blockchain. You’ll learn to interpret simulation results, check for risks, debug failures, and decide between single and batch simulation.
Prerequisites: You need a Risk API key and familiarity with the Transaction Simulator endpoint. You should also know how to construct Solana transactions using @solana/kit or equivalent.

When to Simulate

ScenarioWhy Simulate
Pre-broadcast validationConfirm the transaction will succeed before committing it to the blockchain
Cost estimationGet accurate compute units and fee breakdown before signing
DebuggingIdentify program errors, insufficient balances, or invalid instructions
Risk assessmentCheck if any accounts involved in the transaction are malicious or suspicious
User confirmationShow users exactly what will happen (balance changes, transfers) before they approve

Step 1: Prepare and Encode the Transaction

Build your Solana transaction using any SDK and encode it as base64 or base58.
import {
  pipe,
  createTransactionMessage,
  setTransactionMessageFeePayer,
  setTransactionMessageLifetimeUsingBlockhash,
  appendTransactionMessageInstruction,
  compileTransaction,
  getBase64EncodedWireTransaction,
  createSolanaRpc,
  address,
  lamports,
  type TransactionSigner,
  type Address,
} from "@solana/kit";
import { getTransferSolInstruction } from "@solana-program/system";

// No-op signer for building unsigned transactions. // Simulation doesn't
require signatures, but Kit instruction builders // expect a TransactionSigner
interface for accounts that will sign. function
createNoopSigner<TAddress extends string>( pubkey: Address<TAddress> ):
TransactionSigner<TAddress> { return { address: pubkey, signTransactions: async
(txs) => txs, }; }

const SENDER_ADDRESS = address("SenderAddress11111111111111111111111111111");
const RECIPIENT_ADDRESS = address("RecipientAddress111111111111111111111111");

const rpc = createSolanaRpc("https://api.mainnet-beta.solana.com"); const {
value: latestBlockhash } = await rpc.getLatestBlockhash().send();

// Wrap sender in a no-op signer for the transfer instruction const senderSigner
= createNoopSigner(SENDER_ADDRESS);

// Build the transaction message const transactionMessage = pipe(
createTransactionMessage({ version: 0 }), (m) =>
setTransactionMessageFeePayer(SENDER_ADDRESS, m), (m) =>
setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, m), (m) =>
appendTransactionMessageInstruction( getTransferSolInstruction({ source:
senderSigner, destination: RECIPIENT_ADDRESS, amount: lamports(1_000_000_000n),
// 1 SOL }), m ) );

// Compile and encode as base64 (no signatures needed for simulation) const
compiled = compileTransaction(transactionMessage); const encoded =
getBase64EncodedWireTransaction(compiled);

You don’t need to sign the transaction before simulating. The simulator accepts unsigned transactions, so you can validate before committing any signatures.

Step 2: Submit for Simulation

Single Transaction

curl -X POST "https://api.range.org/v1/simulate/solana/transaction" \
  -H "Authorization: Bearer your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "payload": "ENCODED_TRANSACTION_BASE64",
    "encoding_type": "base64"
  }'
async function simulateTransaction(encodedTx) {
  const response = await fetch(
    'https://api.range.org/v1/simulate/solana/transaction',
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        payload: encodedTx,
        encoding_type: 'base64',
      }),
    },
  );
  return response.json();
}

Batch Simulation

Use batch simulation when you have multiple transactions to validate - either independent transactions or a sequence of dependent operations.
async function simulateBatch(transactions) {
  const response = await fetch(
    'https://api.range.org/v1/simulate/solana/transactions',
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        transactions: transactions.map(tx => ({
          payload: tx,
          encoding_type: 'base64',
        })),
      }),
    },
  );
  return response.json();
}

When to Use Each

ScenarioUse
Single transfer or swapSingle simulation
Multi-step workflow (approve → swap → transfer)Batch simulation
Validating multiple independent transactionsBatch simulation
Portfolio rebalancing across multiple tokensBatch simulation

Step 3: Analyze the Response

The simulation response contains several sections. Here’s what to check and in what order:

Check for Errors First

If the error field is present, the transaction would fail onchain. Other fields may be incomplete.
function checkSimulationSuccess(result) {
  if (result.error) {
    return {
      success: false,
      reason: result.error,
    };
  }
  return { success: true };
}

Review Asset Transfers

The asset_transfers array shows all token and SOL movements per account. Each entry represents a balance change for a specific account and asset.
function summarizeTransfers(result) {
  return (result.asset_transfers || []).map(transfer => ({
    account: transfer.account, // The account affected
    mint: transfer.mint, // Token mint address, or "SOL" for native SOL
    amount: transfer.amount, // Amount transferred (human-readable, not lamports)
    changeType: transfer.change_type, // "Debit" (outflow) or "Credit" (inflow)
  }));
}
For a simple SOL transfer, you’ll see two entries: a Debit from the sender and a Credit to the recipient. For swaps or complex transactions, you’ll see multiple entries across different mints and accounts.

Check Balance Changes

The lamport_changes array shows the net SOL balance change for each address involved in the transaction.
function getBalanceChanges(result: SimulationResult) {
  return (result.lamport_changes || []).map(change => ({
    account: change.account,
    preBalance: change.pre_balance, // Lamports before
    postBalance: change.post_balance, // Lamports after
    delta: change.post_balance - change.pre_balance, // Net change in lamports
  }));
}

Review State Changes

The expected_state_changes object maps each affected account to an array of state changes. Each change includes a humanReadableDiff for display, a suggestedColor (CREDIT or DEBIT), and rawInfo with structured data about the change type.
function getStateChanges(result: SimulationResult) {
  const changes = result.expected_state_changes || {};
  return Object.entries(changes).flatMap(([account, accountChanges]) =>
    accountChanges.map(change => ({
      account,
      description: change.humanReadableDiff, // e.g., "Sent 3.317 SOL", "Receive 268.78 USDC"
      color: change.suggestedColor, // "CREDIT" or "DEBIT"
      kind: change.rawInfo?.kind, // "SOL_TRANSFER", "SPL_TRANSFER", etc.
      asset: change.rawInfo?.data?.asset, // Token metadata (symbol, name, decimals, imageUrl, price)
      diff: change.rawInfo?.data?.diff, // { sign: "PLUS" | "MINUS", digits: bigint }
      counterparty: change.rawInfo?.data?.counterparty, // For SPL transfers
    })),
  );
}
State change types include:
  • SOL_TRANSFER — Native SOL transfers
  • SPL_TRANSFER — Token transfers, swaps, mints, burns
  • Account creations, closures, and other token operations

Check Transaction Costs

function getCosts(result: SimulationResult) {
  const summary = result.transaction_summary;
  const fee = summary.expected_fee;
  return {
    computeUnits: summary.cus_consumed,
    totalFee: fee.total_fee, // Total fee in lamports
    computeFee: fee.compute_fee, // Base compute fee
    prioritizationFee: fee.prioritization_fee,
    jitoTip: fee.jito_tip, // Jito tip if applicable
    feeConfidence: fee.confidence, // "High", "Medium", or "Low"
    programsInvoked: summary.programs_invoked,
  };
}

Fee Confidence Levels

LevelMeaningAction
HighDerived from explicit compute budget instructionsFee is accurate
MediumEstimated from recent prioritization fees for the locked accountsFee is a reasonable estimate
LowNo data available; fallback logic usedConsider setting explicit compute budget

Step 4: Check Transaction Risk

The transaction_risk section provides risk analysis for all accounts involved in the transaction.
function analyzeRisk(result: SimulationResult) {
  const risk = result.transaction_risk;

  // Check for exploit patterns (e.g., address poisoning)
  const exploits = risk.exploit_risks_detected || [];

  // Extract account risk data from the nested structure
  const accountsRisk = risk.accounts_risk_score?.data?.addresses_risk || [];

  // Find risky accounts (score >= 6)
  const riskyAccounts = accountsRisk.flatMap(entry =>
    entry.risk_scores
      .filter(score => score.riskScore >= 6)
      .map(score => ({
        address: score.address,
        score: score.riskScore,
        level: score.riskLevel,
        numHops: score.numHops,
        reasoning: score.reasoning,
        maliciousConnections: score.maliciousAddressesFound,
      })),
  );

  // Get summary stats
  const summary = risk.accounts_risk_score?.data?.summary;

  return {
    hasExploitRisk: exploits.length > 0,
    exploits,
    riskyAccounts,
    summary: summary
      ? {
          totalChecked: summary.total,
          criticalRisk: summary.critical_risk,
          highRisk: summary.high_risk,
          mediumRisk: summary.medium_risk,
          lowRisk: summary.low_risk,
          noRisk: summary.no_risk,
        }
      : null,
    isHighRisk: exploits.length > 0 || riskyAccounts.length > 0,
  };
}
Each risky account includes:
  • riskScore (1-10) and riskLevel (e.g., “Extremely high risk”)
  • numHops — Distance to nearest malicious address
  • reasoning — Explanation of why the address is flagged
  • maliciousAddressesFound — List of connected malicious addresses with their categories (Hack, Scam, etc.)
Risk analysis is performed on the main transaction level. Embedded transactions (e.g., Squads multisig operations) and nested instruction data are not currently included in risk scoring.

Debugging Common Failures

Error PatternLikely CauseFix
Insufficient fundsAccount doesn’t have enough SOL/tokensCheck balances before building the transaction
Account not foundDestination token account doesn’t existCreate the associated token account first
Program errorInvalid instruction data or stateCheck program-specific requirements
Blockhash expiredTransaction took too long to simulateUse a fresh recentBlockhash
Compute budget exceededTransaction is too complexAdd a compute budget instruction with higher limits

Using Logs for Debugging

The transaction_logs array contains the complete execution logs from the Solana runtime. Search for "Program log: Error" or "failed" to pinpoint the failing instruction:
function findErrors(result: SimulationResult) {
  return (result.transaction_logs || []).filter(
    log =>
      log.includes('Error') ||
      log.includes('failed') ||
      log.includes('insufficient'),
  );
}

Complete Simulation Workflow

async function validateBeforeSending(encodedTx: string) {
  const result = await simulateTransaction(encodedTx);

  // 1. Check if simulation succeeded
  if (result.error) {
    return {
      proceed: false,
      reason: `Transaction would fail: ${result.error}`,
    };
  }

  // 2. Check for exploit risks
  const risk = analyzeRisk(result);
  if (risk.hasExploitRisk) {
    return {
      proceed: false,
      reason: 'Exploit risk detected',
      details: risk.exploits,
    };
  }

  // 3. Check for risky accounts
  if (risk.riskyAccounts.length > 0) {
    return {
      proceed: false,
      reason: 'Transaction involves high-risk accounts',
      riskyAccounts: risk.riskyAccounts,
    };
  }

  // 4. Summarize for user confirmation
  return {
    proceed: true,
    transfers: summarizeTransfers(result),
    costs: getCosts(result),
    stateChanges: getStateChanges(result),
  };
}

What’s Next

Last modified on March 2, 2026