For AI agents: the documentation index is at /llms.txt. Markdown versions of pages are available by appending .md to the URL.
Skip to main content

Instruction Handlers

On Solana you react to instructions instead of EVM events. Register a handler with indexer.onInstruction; it fires once for every matched instruction (top-level or inner) of the configured program.

import { indexer } from "envio";

indexer.onInstruction(
{ program: "<PROGRAM_NAME>", instruction: "<INSTRUCTION_NAME>" },
async ({ instruction, context }) => {
// your logic here
},
);

program and instruction are the names you gave them in config.yaml under experimental.programs - not the on-chain program id or IDL name.

Run codegen after config/schema changes

The envio module exposes a unified indexer value plus types derived from your config.yaml and schema.graphql. Run pnpm codegen whenever you change either file. After codegen, program/instruction autocomplete and instruction.params.args / .accounts are typed per instruction.

A complete handler

import { indexer } from "envio";

indexer.onInstruction(
{ program: "TokenMetadata", instruction: "CreateMetadataAccountV3" },
async ({ instruction, context }) => {
const params = instruction.params;
if (!params) return; // discriminator matched but Borsh decode failed

const { args, accounts } = params;
const metadataPda = accounts.metadata;
if (!metadataPda) return;

context.TokenMetadataAccount.set({
id: metadataPda,
mint: accounts.mint ?? "",
updateAuthority: accounts.update_authority,
createdAtSlot: instruction.block.slot,
lastTxSignature: instruction.transaction.signatures[0],
});
},
);

The instruction object

The handler receives { instruction, context }. The instruction carries its own fields plus the block, parent transaction, and scoped logs:

type SvmInstruction = {
programName: string; // the program name from config
instructionName: string; // the instruction name from config
programId: string; // base58
data: string; // 0x-prefixed hex (raw instruction data)
accounts: readonly string[]; // base58 pubkeys, in on-chain order
instructionAddress: readonly number[]; // CPI path, e.g. [0] or [0,1]
isInner: boolean; // true => inner (CPI) instruction
d1?: string; d2?: string; d4?: string; d8?: string; // discriminator prefixes (hex)
params?: SvmInstructionParams; // present when a schema matched (see Decoding)
block: { slot: number; time?: number; hash: string /* + block_fields */ };
transaction: SvmTransaction; // fields per your transaction_fields selection
logs?: readonly SvmLog[]; // present with log_fields
};
  • params is the Borsh-decoded view: { name, args, accounts, extraAccounts }. It's optional - always null-check it. See Decoding & IDLs for the shape of args and accounts.
  • accounts (raw, positional, base58) is always present even when params is not. params.accounts is the same list keyed by your schema's account names.
  • instructionAddress locates the instruction in the transaction's call tree - see Inner instructions.
  • block always carries slot, time (unix seconds, may be undefined), and hash; select height/parentSlot/parentHash via field_selection.block_fields.

instruction.transaction

Always present as an object, but each field is only readable when selected via field_selection.transaction_fields (see field selection). Unselected fields are typed as FieldNotSelected, so reading one is a compile error pointing at the config key to add.

type SvmTransaction = {
signatures: readonly string[]; // signatures[0] is the transaction id
transactionIndex: number;
feePayer?: string;
success?: boolean;
err?: string;
fee?: bigint; // lamports
computeUnitsConsumed?: bigint;
accountKeys: readonly string[];
recentBlockhash?: string;
version?: string;
tokenBalances?: readonly SvmTokenBalance[]; // with token_balance_fields: true
};

Token balances and balance changes

With token_balance_fields: true, each instruction's transaction carries pre/post SPL Token (and Token-2022) balance snapshots. The postAmount − preAmount per token account is the balance change — so this is the cleanest way to capture net value flow without indexing every transfer instruction. The snapshots cover every token account touched by the transaction.

type SvmTokenBalance = {
account?: string; // token account (base58)
mint?: string;
owner?: string;
preAmount?: string; // balance before the tx — u64 as a decimal string
postAmount?: string; // balance after the tx — u64 as a decimal string
};
indexer.onInstruction(
{ program: "Jupiter", instruction: "sharedAccountsRoute" },
async ({ instruction, context }) => {
const txSig = instruction.transaction.signatures[0];
if (!txSig) return;

for (const b of instruction.transaction.tokenBalances ?? []) {
if (!b.account) continue;
const pre = BigInt(b.preAmount ?? "0");
const post = BigInt(b.postAmount ?? "0");
context.TokenDelta.set({
id: `${txSig}:${b.account}`,
account: b.account,
mint: b.mint ?? "",
owner: b.owner,
delta: post - pre, // signed
});
}
},
);
Amounts are strings

preAmount/postAmount (and any u64+ decoded arg) are decimal strings to avoid precision loss. Wrap them in BigInt(...) for arithmetic.

Native SOL balances

Today the handler surfaces token balances. Native SOL (lamport) pre/post balances are available from HyperSync for Solana directly (the balance table) but are not yet exposed as a handler field-selection toggle — let us know if you need them in handlers.

Logs

With log_fields: true, instruction.logs holds the program logs scoped to this instruction:

type SvmLog = { kind: string; message: string };
// kind is one of: invoke | success | failure | log | data | other

Inner instructions (CPIs)

HyperIndex decodes inner instructions (instructions invoked by other programs via cross-program invocation) exactly like top-level ones. Two fields let you reconstruct the call tree:

  • isInnerfalse for a top-level instruction, true for a CPI.
  • instructionAddress — an array describing the path: [0] is the first top-level instruction, [0, 1] is the second inner instruction invoked by it, [0, 1, 2] one level deeper, and so on.
const addr = instruction.instructionAddress;        // e.g. [0, 1]
const path = addr.join("."); // "0.1"
const depth = addr.length - 1; // 1
const parentPath = addr.length > 1 ? addr.slice(0, -1).join(".") : undefined;

By default an instruction config (no is_inner set) matches both inner and outer occurrences, so you capture the full tree. Set is_inner: true/false in config to narrow it.

EVM difference

EVM "internal calls" aren't surfaced as first-class events. On Solana, CPIs are real indexable instructions — a Jupiter route's underlying Raydium/Orca swaps are all visible if you index those programs.

The context object

The handler's context is the same shape as EVM handlers (see the Event Handlers context). For each entity in schema.graphql you get:

context.<Entity>.set(entity);                 // insert or update
await context.<Entity>.get(id); // -> entity | undefined
await context.<Entity>.getOrThrow(id, msg?); // -> entity (throws if missing)
await context.<Entity>.getOrCreate(entity); // get, or set+return the default
await context.<Entity>.getWhere({ field: { _eq: v } }); // query @index fields
context.<Entity>.deleteUnsafe(id);

Plus:

  • context.log — structured logger (info/warn/error/debug).
  • context.effect — call an Effect (external/RPC calls, deduped and cached). Works in Solana handlers.
  • context.chain{ id, isRealtime }. For Solana, id is 0.
  • context.isPreload — see below.

Preload optimization (double-run)

Preload optimization is always on in HyperIndex V3, which means your handler runs twice — once in a parallel preload pass to warm the entity cache, then in order. Reads are idempotent, so this is usually invisible, but guard non-idempotent side effects (e.g. external POSTs) with if (context.isPreload) return;. See Preload Optimization.

Idempotent writes

Backfills and the double-run pass mean handlers should be idempotent: build deterministic entity ids and use set (which is insert-or-update). A common Solana id is the transaction signature combined with the instruction path:

const id = `${instruction.transaction.signatures[0]}:${instruction.instructionAddress.join(".")}`;

Testing

Solana indexers are tested by running the indexer over a pinned slot window against live HyperSync and asserting on the changes it produces. Pass the window as per-chain startBlock/endBlock overrides to process (the Solana chain id is 0):

import { describe, it, expect } from "vitest";
import { createTestIndexer } from "envio";

describe("my solana indexer", () => {
it("indexes instructions in the pinned window", async () => {
const indexer = createTestIndexer();
const result = await indexer.process({
chains: { 0: { startBlock: 420_620_000, endBlock: 420_620_200 } },
});

// result.changes is an array of per-batch checkpoints:
// [{ block, chainId, eventsProcessed, <EntityName>: { sets: [...] } }, ...]
const sets = result.changes.flatMap(
(c) => c.TokenMetadataAccount?.sets ?? [],
);
expect(sets.length).toBeGreaterThan(0);
}, 120_000); // generous timeout - this hits the network
});

To keep a separate test config instead, point the ENVIO_CONFIG environment variable at a config.test.yaml (with its own start_block/end_block) before importing envio.

Because these tests hit the real endpoint, assert on shape and invariants (e.g. "produced rows", "deltas equal post − pre", "saw ≥ 2 programs") rather than exact counts.