Solana Smart Contract RPC Guide — Calling Programs, Reading State, and Subscribing

How to interact with Solana programs (smart contracts) from your application. RPC patterns for reading account state, sending instructions, and subscribing to changes.

BoltTx Team··8 min read
solanasmart-contractprogramrpcanchordeveloper

If you're building anything non-trivial on Solana, you'll be calling smart contracts (Solana calls them "programs"). The RPC layer is where this happens — every program interaction goes through some kind of RPC method. Most developers learn the basic patterns and stop; the production-grade patterns require knowing a bit more about what's actually happening underneath.

This piece covers how to interact with Solana programs efficiently from client code: reading state, sending instructions, subscribing to changes, and the patterns that distinguish working integrations from production-grade ones.

What "Smart Contract RPC" Actually Means

On Solana, the term is "program" rather than "smart contract" — but the concept is the same: code deployed on-chain that you can interact with from clients. RPC interactions with programs fall into three categories:

Reads — query account state owned by the program. Often the dominant traffic.

Writes — submit transactions that call program instructions. These are the on-chain actions that change state.

Subscriptions — get notified when program-owned account state changes, or when the program emits log events.

Each has its own RPC patterns. Getting them right is the difference between a snappy app and a slow one.

Reading Program State

The standard Solana RPC reads all work for program state:

// Single account
const accountInfo = await connection.getAccountInfo(pdaPublicKey);

// Multiple accounts in one round-trip
const accounts = await connection.getMultipleAccountsInfo([
  pda1, pda2, pda3,
]);

// All accounts owned by a program (use filters!)
const accounts = await connection.getProgramAccounts(programId, {
  filters: [
    { dataSize: 165 },
    { memcmp: { offset: 0, bytes: ownerPublicKey.toBase58() } },
  ],
});

The patterns that matter:

Use PDAs as your primary access pattern. Programs typically store state in Program Derived Addresses. Compute the PDA client-side, fetch the account, deserialise.

Always use filters with getProgramAccounts. Without filters, you can pull megabytes of irrelevant data. Filters apply server-side and are much faster.

Cache deserialised state. If the same program-owned account is read frequently, cache it. Use websocket subscriptions or smart re-fetch logic to keep cache fresh.

Use getMultipleAccountsInfo for batched reads. One round-trip beats N. Most production code does this poorly.

Calling Program Instructions

To execute a program's instruction, you build a transaction containing the instruction and submit it:

import { Transaction, TransactionInstruction } from "@solana/web3.js";

const ix = new TransactionInstruction({
  programId: yourProgramId,
  keys: [
    { pubkey: account1, isSigner: false, isWritable: true },
    { pubkey: account2, isSigner: false, isWritable: false },
  ],
  data: serialisedInstructionData,
});

const tx = new Transaction().add(ix);
const signature = await connection.sendTransaction(tx, [signer]);

For Anchor programs, the SDK handles serialisation:

import * as anchor from "@coral-xyz/anchor";

const tx = await program.methods
  .yourInstruction(arg1, arg2)
  .accounts({
    account1: pda1,
    account2: pda2,
  })
  .signers([signer])
  .rpc();

The Anchor pattern is much friendlier; raw web3.js is more work but more flexible.

Account Validation

Solana programs require specific accounts to be passed in the right positions. A common bug:

Anchor catches most of these client-side via account validation macros. Without Anchor, you have to be careful and read the program's IDL or source.

Subscribing to Program Events

WebSocket subscriptions are key for reactive applications:

Account change subscription — watch a specific PDA for state changes:

const subId = connection.onAccountChange(
  pdaPublicKey,
  (accountInfo, context) => {
    const decoded = deserialise(accountInfo.data);
    handleStateChange(decoded);
  },
  "confirmed"
);

Program logs subscription — watch a program for specific log events (Anchor programs emit structured events you can parse):

const subId = connection.onLogs(
  programId,
  (logs, context) => {
    if (logs.err === null) {
      // Parse the events from logs.logs
    }
  },
  "confirmed"
);

Signature subscription — get notified when a specific transaction confirms (useful for confirming sent transactions without polling):

const subId = connection.onSignature(
  signature,
  (result, context) => {
    if (result.err === null) {
      // Transaction confirmed
    }
  },
  "confirmed"
);

For programs with high event volume, subscriptions can be expensive (lots of WebSocket traffic). For these, consider purpose-built streaming subscriptions from your RPC provider.

Common Smart Contract RPC Patterns

Patterns that work well in production:

Optimistic UI on processed commitment, reconcile to confirmed. Show the user the action succeeded as soon as it lands at processed; verify at confirmed in the background.

Subscriptions for live updates, polling for backups. Subscriptions can drop; polling at low frequency catches missed updates.

Cached read data with subscription-based invalidation. Store the most recent state; invalidate on subscription notifications.

Transaction simulation before submission. Catches obvious bugs without spending fees. (Combine with skipPreflight: true for production sends — simulate explicitly during dev, skip during prod.)

Per-signature telemetry. For every program call your app makes, log the signature, instruction, accounts, and outcome. Lets you debug failures without re-running.

Common Mistakes

Looping over getAccountInfo instead of getMultipleAccountsInfo. N round-trips when 1 would suffice.

Calling getProgramAccounts without filters. Pulls everything; slow and expensive.

Polling instead of subscribing. For reactive apps, subscriptions are dramatically more efficient.

Ignoring program log events. Anchor programs emit structured events; many clients ignore them and re-fetch state to detect changes.

Hardcoding deserialisation instead of using IDLs. If the program upgrades, your client breaks. Use the IDL to generate deserialisation code.

Not handling commitment levels right. Reading at "processed" can give you data that's later reorganised. Choose commitment based on what you're doing.

Submitting program calls without confirming. Same as any sendTransaction — confirm afterwards.

Anchor-Specific Patterns

If your program is Anchor-based:

// Read program account with auto-deserialisation
const account = await program.account.yourAccountType.fetch(pda);

// Read multiple accounts of the same type with filters
const accounts = await program.account.yourAccountType.all([
  { memcmp: { offset: 8, bytes: ownerKey.toBase58() } },
]);

// Listen to program events
const listener = program.addEventListener("YourEvent", (event, slot) => {
  handleEvent(event);
});

// Cleanup
program.removeEventListener(listener);

Anchor's account.fetch handles the deserialisation; account.all handles filtered queries. The events listener is built on log subscriptions.

RPC Choice for Smart Contract Workloads

Different patterns of program interaction need different RPCs:

Read-heavy applications (dashboards, analytics, NFT browsers). Want a read-optimised RPC. Generous query limits, fast getProgramAccounts, parsed transaction history if relevant.

Write-heavy applications (DEXes, trading apps, programs that submit many transactions). Want a write-optimised RPC. Sub-second confirmation, Anti-MEV routing, SWQoS support.

Hybrid (most dApps). Use both. Read provider for queries, write provider for sends.

For trading-adjacent programs (DEX UIs, swap aggregators, lending dApps), the write side often dominates the user experience even though read traffic is higher in volume — users notice slow swaps more than slow page loads.

What to Do This Week

If you're building a program-integrated application:

  1. Use Anchor if you can. The account validation and IDL handling save a lot of bugs.
  2. Use getMultipleAccountsInfo for batched reads. Don't loop.
  3. Filter your getProgramAccounts queries. Always.
  4. Subscribe to events, don't poll. For reactive UI.
  5. Pick a write RPC with Anti-MEV. Program calls that involve swaps are sandwich targets.
  6. Add per-signature telemetry on the write side. Track which program calls land and which don't.
  7. Cache aggressively for slow-changing program state. Token metadata, program owners, etc.

Try BoltTx for Smart Contract Writes

For program calls that involve transactions:

import { Connection } from "@solana/web3.js";

const writeConnection = new Connection(
  "https://bolttx.io/?api-key=YOUR_API_KEY",
  "processed"
);

const signature = await writeConnection.sendTransaction(tx, signers, {
  skipPreflight: true,
  maxRetries: 0,
});

Sub-second confirmation, native Anti-MEV, SWQoS-aware delivery. Pair with a read-focused RPC for the query side.

Free tier signup. Run real program calls through it for a week and compare landing rate.

FAQ

Can I read program state without an RPC? You can read directly from a Solana node, but practically speaking you use an RPC for indirection. Self-hosting just for reads usually doesn't make economic sense.

Should I use IDLs? For Anchor programs, yes — Anchor depends on them. For non-Anchor programs, IDLs may not exist; you write deserialisation manually.

What's the max account size on Solana? 10 MB. Most program accounts are tiny (<1 KB). Larger accounts need realloc.

How do I handle program upgrades from a client? Re-fetch the IDL after upgrades; regenerate client code. Old data formats may need migration logic.

Can I batch program calls in one transaction? Yes — multiple instructions in one transaction. Constraints: total CU budget, total transaction size (1232 bytes), proper account ordering.

Further Reading

Back to all posts