> For the complete documentation index, see [llms.txt](https://docs.envio.dev/llms.txt).

# 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.

```typescript
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.

:::note 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

```typescript
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`:

```typescript
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`. Field names: `address`, `transactionAccountIndex`, `isSigner`, `isWritable`, `lamports.pre`, `lamports.post`, `token.mint`, `token.owner`, `token.decimals`, `token.preAmount`, `token.postAmount`. See [account activity](#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.

To type a helper or a handler declared outside the registration, pass the
selection and the config names to the exported types:

```typescript
import type { SvmInstruction, SvmOnInstructionHandler } from "envio";

type TransferInstruction = SvmInstruction<typeof fields, "SplToken", "transferChecked">;
const handleTransfer: SvmOnInstructionHandler<typeof fields, "SplToken", "transferChecked"> =
  async ({ instruction, context }) => { /* ... */ };
```

:::tip `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

```typescript
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 }[]; // kind: "invoke" | "success" | "failed" | "consumed" | "log" | "data" | other
  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](/docs/HyperIndex/solana/decoding#supported-argument-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](#inner-instructions-cpis).

### `instruction.transaction`

```typescript
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[]; // static keys, then lookup-table writable, then readonly
  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.

```typescript
type SvmAccountActivity = {
  address: string;
  transactionAccountIndex: number;
  isSigner: boolean;
  isWritable: boolean;
  lamports: { pre: bigint; post: bigint } | undefined;
  token:
    | {
        mint: string;
        owner: string; // owner after the tx, or before it if the tx closed the account
        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.

```typescript
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
      });
    }
  },
);
```

:::tip 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:

```typescript
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`](/docs/HyperIndex/solana/slot-handlers) for `_lte` / `_every`. |

Only slots you named in `accounts` are filterable (`_` placeholders and unnamed
trailing slots are not), and only the first 10 positions (0-9). Naming a later
slot in `where.accounts` throws at registration:
`Account "<name>" is at position <n>, and only the first 10 accounts of an instruction can be filtered.`
A bad `where` (an unknown key, an empty pubkey list, a value that isn't a
base58 pubkey) also throws at registration, with a message that names the
program and instruction.

## 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.

```typescript
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`.

:::info 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](/docs/HyperIndex/effect-api), and
`context.isPreload`. See the
[Event Handlers context](/docs/HyperIndex/event-handlers#context-object).

Two things carry over from EVM that matter here:

- `context.chain` is `{ id, isRealtime }`, and `id` is `7565164` on Solana mainnet.
- [Preload optimization](/docs/HyperIndex/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:

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

## Testing

Solana indexers use the same [test framework](/docs/HyperIndex/testing) 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.

```typescript
import { createTestIndexer } from "envio";

const SOLANA = 7565164;

const indexer = createTestIndexer();
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:

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

That needs `ENVIO_API_TOKEN`. Without one the run fails at once with
`An Envio API token is required for using HyperSync as a data-source.` A token
the server rejects (HTTP 401) is retried with backoff rather than failing fast,
so that 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](/docs/HyperIndex/solana/decoding) - what `args` and `accounts` contain.
- [Configuration](/docs/HyperIndex/solana/configuration): programs, instructions, discriminators, account slots.
- [Slot Handlers](/docs/HyperIndex/solana/slot-handlers): the other Solana handler type.
- [How to Index and Track Stablecoin Transfers on Solana](/blog/index-solana-stablecoin-transfers): a tested indexer that uses `where` filters, account activity and inner instructions together.
