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.
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;
| Knob | Adds |
|---|---|
instruction | args, accounts, accountArguments, programId, data, path, isInner. |
transaction | transactionIndex, signature, feePayer, success, err, fee, computeUnitsConsumed, accountKeys, recentBlockhash, version, allSignatures. |
accountActivity | Per-account balances and token info on instruction.accounts.<name>.activity and instruction.transaction.accountActivities - see account activity. |
block | time, hash, height, parentSlot, parentHash on top of the always-present slot. |
log | instruction.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 */ };
};
argsis keyed by the argument names from the IDL or the inlineargslayout, typed after codegen. See supported types.accountsis keyed by the slot names you declared. Each entry is{ address, accountName, instructionAccountIndex, activity }.accountArgumentsis the positional list of addresses, including slots you never named.pathlocates 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
});
}
},
);
bigintBalances 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,
);
| Key | Effect |
|---|---|
accounts | Match 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. |
isInner | true matches only inner (CPI) instructions, false only top-level ones. Omit it to match both. |
block.slot._gte | A 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:falsefor a top-level instruction,truefor 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 "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.chainis{ id, isRealtime }, andidis7565164on 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.
Related
- Decoding Instructions - what
argsandaccountscontain. - Configuration: programs, instructions, discriminators, account slots.
- Slot Handlers: the other Solana handler type.
- How to Index and Track Stablecoin Transfers on Solana: a tested indexer that uses
wherefilters, account activity and inner instructions together.