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 is the name you gave it under the top-level programs in config.yaml. instruction is a name from that program's instructions list, or - when the program points at an idl - any instruction the IDL declares.

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.args / instruction.accounts are typed per instruction.

A complete handler

import { indexer } from "envio";

/** Only the listed fields are fetched, so keep it to what the handlers read. */
const fields = {
instruction: ["accounts", "args", "path"],
transaction: ["signature", "transactionIndex"],
block: ["time"],
} as const;

indexer.onInstruction(
{ program: "SplToken", instruction: "transferChecked", fields },
async ({ instruction, context }) => {
context.Transfer.set({
id: `${instruction.block.slot}-${instruction.transaction.transactionIndex}-${instruction.path.join(".")}`,
amount: instruction.args.amount, // bigint, decoded from the u64
source: instruction.accounts.source.address, // base58
destination: instruction.accounts.destination.address,
signer: instruction.accounts.authority.address,
txSignature: instruction.transaction.signature,
slot: instruction.block.slot,
timestamp: instruction.block.time,
});
},
);

An instruction whose data the configured layout rejects is skipped rather than delivered undecoded, so args and accounts need no null checks.

Selecting fields

A registration carries only the data it asks for. List it in fields:

const fields = {
instruction: ["args", "accounts", "path", "isInner", "programId", "data", "accountArguments"],
transaction: ["signature", "feePayer", "success", "fee", "computeUnitsConsumed"],
accountActivity: ["token.mint", "token.owner", "token.decimals", "lamports.pre", "lamports.post"],
block: ["time", "hash", "height", "parentSlot", "parentHash"],
log: ["kind", "message"],
} as const;
KnobAdds
instructionargs, accounts, accountArguments, programId, data, path, isInner.
transactiontransactionIndex, signature, feePayer, success, err, fee, computeUnitsConsumed, accountKeys, recentBlockhash, version, allSignatures.
accountActivityPer-account balances and token info on instruction.accounts.<name>.activity and instruction.transaction.accountActivities - see account activity.
blocktime, hash, height, parentSlot, parentHash on top of the always-present slot.
loginstruction.logs, the program logs scoped to this instruction.

Reading a field you didn't select is a compile error naming the knob to add, not a runtime undefined. Write the selection inline in the registration, or declare it as const (as above) so its element types still name the fields.

instruction.programName, instruction.instructionName, instruction.discriminator and instruction.block.slot are always available.

signature, not allSignatures[0]

The identifying transaction id is the scalar instruction.transaction.signature. allSignatures is the array of every signer's signature and is selected separately - selecting signature doesn't give you allSignatures. Nearly every handler wants the scalar.

The instruction object

type SvmInstruction = {
programName: string; // the program name from config
instructionName: string; // the instruction name from config or IDL
discriminator: string; // the matched hex prefix, e.g. "0x0c"
programId: string; // base58
data: Uint8Array; // raw instruction data
path: readonly number[]; // CPI path, e.g. [0] or [0, 1]
isInner: boolean; // true => inner (CPI) instruction
args: { ... }; // decoded Borsh arguments, typed per instruction
accounts: { [name]: SvmInstructionAccount }; // named account slots
accountArguments: readonly string[]; // every account address, positionally
logs: readonly { kind: string; message: string }[];
transaction: SvmTransaction;
block: { slot: number /* + selected block fields */ };
};
  • args is keyed by the argument names from the IDL or the inline args layout, typed after codegen. See supported types.
  • accounts is keyed by the slot names you declared. Each entry is { address, accountName, instructionAccountIndex, activity }.
  • accountArguments is the positional list of addresses, including slots you never named.
  • path locates the instruction in the transaction's call tree - see inner instructions.

instruction.transaction

type SvmTransaction = {
transactionIndex: number;
signature: string; // the transaction id, a scalar
feePayer: string;
success: boolean;
err: string | undefined;
fee: bigint; // lamports
computeUnitsConsumed: bigint | undefined;
accountKeys: readonly string[];
recentBlockhash: string;
version: string | undefined;
allSignatures: readonly string[];
accountActivities: readonly SvmAccountActivity[]; // with fields.accountActivity
};

The object is always there; each field is readable only when the registration selected it, so a missing selection is a compile error on the property rather than a crash on undefined.

Account activity

Selecting fields.accountActivity attaches per-account, per-transaction activity: pre/post lamport balances and pre/post SPL Token (and Token-2022) balances. postAmount − preAmount is the balance change, which is the cleanest way to capture net value flow without indexing every transfer.

type SvmAccountActivity = {
address: string;
transactionAccountIndex: number;
isSigner: boolean;
isWritable: boolean;
lamports: { pre: bigint; post: bigint } | undefined;
token:
| {
mint: string;
owner: string;
decimals: number;
preAmount: bigint | undefined; // absent if the account was created in the tx
postAmount: bigint | undefined; // absent if it was closed in the tx
}
| undefined;
};

It arrives in two places: on a named account as instruction.accounts.<name>.activity (the account's own row, or undefined when the transaction reports none for it), and on instruction.transaction.accountActivities for every account the transaction touched.

const fields = {
instruction: ["accounts"],
transaction: ["signature"],
accountActivity: ["token.mint", "token.decimals", "token.preAmount", "token.postAmount"],
} as const;

indexer.onInstruction(
{ program: "Jupiter", instruction: "sharedAccountsRoute", fields },
async ({ instruction, context }) => {
for (const { address, token } of instruction.transaction.accountActivities) {
if (!token) continue;
context.TokenDelta.set({
id: `${instruction.transaction.signature}:${address}`,
mint: token.mint,
decimals: token.decimals,
delta: (token.postAmount ?? 0n) - (token.preAmount ?? 0n), // signed
});
}
},
);
Amounts are bigint

Balances are raw base units typed as bigint, so use ?? 0n rather than ?? "0" for the absent case. Both amounts absent means the entry carries no movement at all, which is worth distinguishing from a genuine zero.

Filtering with where

where narrows a registration server-side, so filtered-out instructions are never fetched or decoded:

indexer.onInstruction(
{
program: "SplToken",
instruction: "transferChecked",
fields,
where: {
accounts: { mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" },
isInner: false,
block: { slot: { _gte: 445_000_000 } },
},
},
handler,
);
KeyEffect
accountsMatch named account slots against a pubkey or a list of them. Keys within one group are AND-ed; pass an array of groups to OR them.
isInnertrue matches only inner (CPI) instructions, false only top-level ones. Omit it to match both.
block.slot._gteA per-registration start slot. It overrides the chain's start_slot for this handler. Only _gte is supported here - use indexer.onSlot for _lte / _every.

Only slots you named in accounts are filterable (_ placeholders and unnamed trailing slots are not).

Inner instructions (CPIs)

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

  • isInner: false for a top-level instruction, true for a CPI.
  • path: the position in the tree. [0] is the first top-level instruction, [0, 1] the second inner instruction it invoked, [0, 1, 2] one level deeper.
const path = instruction.path; // e.g. [0, 1]
const id = path.join("."); // "0.1"
const depth = path.length - 1; // 1
const parent = path.length > 1 ? path.slice(0, -1).join(".") : undefined;

A registration matches both inner and outer occurrences by default; narrow it with where: { isInner: true } or false.

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

context is the same as in EVM handlers - the per-entity operations (set, get, getOrThrow, getOrCreate, getWhere, deleteUnsafe), context.log, context.effect for Effects, and context.isPreload. See the Event Handlers context.

Two things carry over from EVM that matter here:

  • context.chain is { id, isRealtime }, and id is 7565164 on Solana mainnet.
  • Preload optimization runs every handler twice, so keep writes idempotent and guard non-idempotent side effects with if (context.isPreload) return;.

A deterministic Solana entity id combines the slot, the transaction index and the instruction path:

const id = `${instruction.block.slot}-${instruction.transaction.transactionIndex}-${instruction.path.join(".")}`;

Testing

Solana indexers use the same test framework as EVM ones. Two things are SVM-specific: chain overrides are keyed by the numeric chain id (7565164), and simulate items describe instructions rather than events.

const SOLANA = 7565164;

await indexer.process({
chains: {
[SOLANA]: {
simulate: [
{
program: "SplToken",
instruction: "transferChecked",
slot: 445_000_000,
path: [1, 0],
args: { amount: 250_000n, decimals: 6 },
accounts: {
source: { address: SOURCE },
mint: { address: USDC },
destination: { address: DESTINATION },
authority: { address: AUTHORITY },
},
block: { time: 1_800_000_000 },
transaction: { signature: SIGNATURE, transactionIndex: 4 },
},
],
},
},
});

A simulated instruction defaults its data to the configured discriminator bytes and its path to [0], and only runs when its slot is inside the configured range. transaction.accountActivities entries are joined onto the named accounts at process time, so a handler reading accounts.destination.activity.token.mint is testable without a live endpoint.

To run against real data, pass a pinned slot window instead and let the indexer fetch it from HyperSync:

await indexer.process({
chains: { [SOLANA]: { startBlock: 445_000_000, endBlock: 445_000_060 } },
});

That needs ENVIO_API_TOKEN - without one, POST /query 401s are retried rather than failing fast, so the run hangs until the test timeout instead of erroring. Since it hits the real endpoint, assert on shape and invariants ("produced rows", "delta equals post minus pre") rather than exact counts.