Developer Guide

Soroban JSON RPC Explained: How to Query Smart Contracts on Stellar

Soroban brings smart contracts to Stellar, and JSON-RPC is how you interact with them. This guide breaks down everything you need to know about Soroban RPC—from basic concepts to advanced querying techniques.

What is Soroban RPC?

Soroban RPC is a JSON-RPC 2.0 service that provides access to Stellar's smart contract platform. Unlike the REST-based Horizon API (which handles traditional Stellar operations), Soroban RPC is specifically designed for smart contract interactions.

Key Responsibilities:

  • Simulating contract calls before submission
  • Submitting contract transactions
  • Querying contract state and events
  • Fetching fee estimates and network status
  • JSON-RPC Basics

    JSON-RPC is a simple protocol for remote procedure calls using JSON. Every request follows this structure:

    {
      "jsonrpc": "2.0",
      "id": 1,
      "method": "methodName",
      "params": {}
    }

    Responses include either a result or an error:

    {
      "jsonrpc": "2.0",
      "id": 1,
      "result": { ... }
    }

    Getting Started with LumenQuery Soroban RPC

    LumenQuery provides production-ready Soroban RPC infrastructure. Here's how to connect:

    const SOROBAN_RPC_URL = 'https://rpc.lumenquery.io';
    const API_KEY = 'lq_your_api_key';
    
    async function rpcCall(method, params = {}) {
      const response = await fetch(SOROBAN_RPC_URL, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-API-Key': API_KEY,
        },
        body: JSON.stringify({
          jsonrpc: '2.0',
          id: Date.now(),
          method,
          params,
        }),
      });
    
      const data = await response.json();
    
      if (data.error) {
        throw new Error(`RPC Error: ${data.error.message}`);
      }
    
      return data.result;
    }

    Core RPC Methods

    Network Health and Status

    // Check if the RPC server is healthy
    const health = await rpcCall('getHealth');
    console.log(health);
    // { "status": "healthy" }
    
    // Get network information
    const network = await rpcCall('getNetwork');
    console.log(network);
    // {
    //   "friendbotUrl": "https://friendbot.stellar.org/",
    //   "passphrase": "Public Global Stellar Network ; September 2015",
    //   "protocolVersion": "21"
    // }
    
    // Get the latest ledger
    const ledger = await rpcCall('getLatestLedger');
    console.log(ledger);
    // {
    //   "id": "...",
    //   "protocolVersion": 21,
    //   "sequence": 53012845
    // }

    Fee Statistics

    Understanding fees is crucial for contract calls:

    const feeStats = await rpcCall('getFeeStats');
    console.log(feeStats);
    // {
    //   "sorobanInclusionFee": {
    //     "max": "210",
    //     "min": "100",
    //     "mode": "100",
    //     "p10": "100",
    //     "p20": "100",
    //     "p30": "100",
    //     "p40": "100",
    //     "p50": "100",
    //     "p60": "100",
    //     "p70": "100",
    //     "p80": "100",
    //     "p90": "100",
    //     "p95": "100",
    //     "p99": "200",
    //     "transactionCount": "50",
    //     "ledgerCount": 50
    //   },
    //   "inclusionFee": { ... },
    //   "latestLedger": 53012845
    // }

    Invoking Smart Contracts

    Step 1: Simulate the Transaction

    Before submitting a contract call, always simulate it first. This validates the call and returns resource requirements:

    const simulation = await rpcCall('simulateTransaction', {
      transaction: 'AAAAAgAAAA...', // Base64-encoded transaction XDR
    });
    
    console.log(simulation);
    // {
    //   "transactionData": "...",
    //   "minResourceFee": "94813",
    //   "events": [...],
    //   "results": [{
    //     "auth": [...],
    //     "xdr": "..." // Return value
    //   }],
    //   "cost": {
    //     "cpuInsns": "2893756",
    //     "memBytes": "1234567"
    //   },
    //   "latestLedger": 53012845
    // }

    Step 2: Build and Sign the Transaction

    Use the simulation result to build the final transaction:

    import { SorobanRpc, TransactionBuilder, Networks, Operation } from '@stellar/stellar-sdk';
    
    const server = new SorobanRpc.Server('https://rpc.lumenquery.io', {
      headers: { 'X-API-Key': 'lq_your_api_key' },
    });
    
    // Build the contract call
    const contract = new Contract(contractId);
    const operation = contract.call('increment', ...[]);
    
    let transaction = new TransactionBuilder(account, {
      fee: '100',
      networkPassphrase: Networks.PUBLIC,
    })
      .addOperation(operation)
      .setTimeout(30)
      .build();
    
    // Simulate to get resource requirements
    const simulated = await server.simulateTransaction(transaction);
    
    // Prepare the transaction with actual resource footprint
    transaction = SorobanRpc.assembleTransaction(transaction, simulated).build();
    
    // Sign the transaction
    transaction.sign(keypair);

    Step 3: Submit the Transaction

    const submitResult = await rpcCall('sendTransaction', {
      transaction: transaction.toXDR(),
    });
    
    console.log(submitResult);
    // {
    //   "status": "PENDING",
    //   "hash": "abc123...",
    //   "latestLedger": 53012846,
    //   "latestLedgerCloseTime": "1707849600"
    // }

    Step 4: Poll for Results

    async function waitForTransaction(hash, timeout = 30000) {
      const start = Date.now();
    
      while (Date.now() - start < timeout) {
        const result = await rpcCall('getTransaction', { hash });
    
        if (result.status === 'SUCCESS') {
          return result;
        }
    
        if (result.status === 'FAILED') {
          throw new Error(`Transaction failed: ${JSON.stringify(result)}`);
        }
    
        // Still pending, wait and retry
        await new Promise((r) => setTimeout(r, 1000));
      }
    
      throw new Error('Transaction timeout');
    }
    
    const result = await waitForTransaction(submitResult.hash);
    console.log('Transaction successful:', result);

    Querying Contract State

    Reading Ledger Entries

    Use getLedgerEntries to read contract storage:

    import { xdr, Address } from '@stellar/stellar-sdk';
    
    // Build the storage key
    const contractAddress = Address.fromString(contractId);
    const key = xdr.LedgerKey.contractData(
      new xdr.LedgerKeyContractData({
        contract: contractAddress.toScAddress(),
        key: xdr.ScVal.scvSymbol('counter'),
        durability: xdr.ContractDataDurability.persistent(),
      })
    );
    
    const result = await rpcCall('getLedgerEntries', {
      keys: [key.toXDR('base64')],
    });
    
    console.log(result);
    // {
    //   "entries": [{
    //     "key": "...",
    //     "xdr": "...",
    //     "lastModifiedLedgerSeq": 53012800,
    //     "liveUntilLedgerSeq": 53112800
    //   }],
    //   "latestLedger": 53012850
    // }
    
    // Decode the value
    const entry = xdr.LedgerEntryData.fromXDR(result.entries[0].xdr, 'base64');
    const contractData = entry.contractData();
    console.log('Counter value:', contractData.val().u32());

    Querying Contract Events

    Events are the primary way contracts communicate what happened during execution:

    const events = await rpcCall('getEvents', {
      startLedger: 53012800,
      filters: [
        {
          type: 'contract',
          contractIds: [contractId],
          topics: [
            ['*'], // Match any first topic
            ['*'], // Match any second topic
          ],
        },
      ],
      pagination: {
        limit: 100,
      },
    });
    
    console.log(events);
    // {
    //   "events": [
    //     {
    //       "type": "contract",
    //       "ledger": "53012820",
    //       "ledgerClosedAt": "2024-02-13T12:00:00Z",
    //       "contractId": "CAB...",
    //       "id": "...",
    //       "pagingToken": "...",
    //       "topic": ["AAAADwAAAAlpbmNyZW1lbnQ=", ...],
    //       "value": "AAAAAwAAAAU="
    //     }
    //   ],
    //   "latestLedger": 53012850
    // }

    Filtering Events

    You can filter events by topic for more specific queries:

    import { xdr } from '@stellar/stellar-sdk';
    
    // Create a topic filter for "transfer" events
    const transferTopic = xdr.ScVal.scvSymbol('transfer').toXDR('base64');
    
    const transfers = await rpcCall('getEvents', {
      startLedger: 53012800,
      filters: [
        {
          type: 'contract',
          contractIds: [tokenContractId],
          topics: [[transferTopic], ['*'], ['*']], // transfer(from, to)
        },
      ],
      pagination: { limit: 50 },
    });

    Horizon vs Soroban RPC

    Understanding when to use each API is crucial:

    FeatureHorizon APISoroban RPC
    ProtocolRESTJSON-RPC 2.0
    Use CaseTraditional Stellar opsSmart contracts
    Transaction TypesPayments, trustlines, offersContract invocations
    State QueriesAccount balances, orderbookContract storage
    EventsOperation historyContract events
    SimulationNoYes
    Base URLapi.lumenquery.iorpc.lumenquery.io

    When to Use Horizon

  • Querying account balances and trustlines
  • Fetching transaction history
  • Working with the DEX (offers, orderbook)
  • Traditional Stellar operations (payments, trustlines)
  • When to Use Soroban RPC

  • Deploying and invoking smart contracts
  • Reading contract state
  • Querying contract events
  • Simulating contract calls
  • Fee estimation for contract transactions
  • Using the Stellar SDK

    The official Stellar SDK simplifies Soroban RPC interactions:

    import { SorobanRpc, Contract, Networks, Keypair } from '@stellar/stellar-sdk';
    
    // Initialize the server with LumenQuery
    const server = new SorobanRpc.Server('https://rpc.lumenquery.io', {
      headers: { 'X-API-Key': 'lq_your_api_key' },
    });
    
    // Load a contract
    const contract = new Contract(contractId);
    
    // Get the account
    const account = await server.getAccount(publicKey);
    
    // Build a contract call
    const transaction = new TransactionBuilder(account, {
      fee: '100000',
      networkPassphrase: Networks.PUBLIC,
    })
      .addOperation(contract.call('my_function', ...args))
      .setTimeout(30)
      .build();
    
    // Simulate the transaction
    const simulated = await server.simulateTransaction(transaction);
    
    if (SorobanRpc.Api.isSimulationError(simulated)) {
      throw new Error(`Simulation failed: ${simulated.error}`);
    }
    
    // Prepare and sign
    const prepared = SorobanRpc.assembleTransaction(transaction, simulated);
    prepared.sign(keypair);
    
    // Submit
    const response = await server.sendTransaction(prepared.build());
    
    // Wait for confirmation
    if (response.status === 'PENDING') {
      const result = await server.getTransaction(response.hash);
      // Handle result
    }

    Error Handling

    Soroban RPC returns specific error codes:

    async function handleRpcCall(method, params) {
      try {
        const result = await rpcCall(method, params);
        return result;
      } catch (error) {
        if (error.code === -32600) {
          console.error('Invalid request');
        } else if (error.code === -32601) {
          console.error('Method not found');
        } else if (error.code === -32602) {
          console.error('Invalid params');
        } else if (error.code === -32603) {
          console.error('Internal error');
        } else {
          console.error('Unknown error:', error);
        }
        throw error;
      }
    }

    Production Best Practices

    1. Always Simulate First

    Never submit a contract transaction without simulating it:

    async function safeContractCall(transaction) {
      const simulation = await server.simulateTransaction(transaction);
    
      if (SorobanRpc.Api.isSimulationError(simulation)) {
        throw new Error(`Simulation failed: ${simulation.error}`);
      }
    
      if (simulation.results?.some((r) => r.error)) {
        throw new Error('Contract execution would fail');
      }
    
      return SorobanRpc.assembleTransaction(transaction, simulation);
    }

    2. Handle TTL and State Archival

    Soroban contracts have time-to-live (TTL) for state:

    // Check if state entry is about to expire
    const entries = await server.getLedgerEntries([stateKey]);
    const entry = entries.entries[0];
    const currentLedger = entries.latestLedger;
    
    if (entry.liveUntilLedgerSeq - currentLedger < 10000) {
      console.warn('State entry expiring soon, consider extending TTL');
    }

    3. Use Appropriate Timeouts

    Contract calls can be resource-intensive:

    const response = await fetch(SOROBAN_RPC_URL, {
      method: 'POST',
      headers: { ... },
      body: JSON.stringify({ ... }),
      signal: AbortSignal.timeout(30000), // 30 second timeout
    });

    Why LumenQuery for Production

    LumenQuery provides enterprise-grade Soroban RPC infrastructure:

  • High availability - 99.9% uptime SLA
  • Low latency - Optimized for fast responses
  • Rate limits - Generous limits for production workloads
  • No maintenance - We handle the infrastructure
  • Support - Expert help when you need it

  • *Ready to build with Soroban? Sign up for LumenQuery and get production-ready RPC infrastructure today.*