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:
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:
| Feature | Horizon API | Soroban RPC |
|---|---|---|
| Protocol | REST | JSON-RPC 2.0 |
| Use Case | Traditional Stellar ops | Smart contracts |
| Transaction Types | Payments, trustlines, offers | Contract invocations |
| State Queries | Account balances, orderbook | Contract storage |
| Events | Operation history | Contract events |
| Simulation | No | Yes |
| Base URL | api.lumenquery.io | rpc.lumenquery.io |
When to Use Horizon
When to Use Soroban RPC
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:
*Ready to build with Soroban? Sign up for LumenQuery and get production-ready RPC infrastructure today.*