Skip to content

feat: Add SPL Token batch instruction decoding#903

Open
askov wants to merge 6 commits intosolana-foundation:masterfrom
hoodieshq:feat/token-batch-instruction
Open

feat: Add SPL Token batch instruction decoding#903
askov wants to merge 6 commits intosolana-foundation:masterfrom
hoodieshq:feat/token-batch-instruction

Conversation

@askov
Copy link
Contributor

@askov askov commented Mar 24, 2026

Description

Parse and render the Token/Token-2022 Batch instruction (0xff), which packs multiple sub-instructions into a single instruction's data. No published SDK exposes a decoder for this wire format, so the parser is hand-rolled against the on-chain layout.

Type of change

  • New feature

Screenshots

localhost_3000_tx_38jYWsMHu4MEKZSa38k7dYYHurWuVDEw21smo5euvKWwxu8rKy9MoEg1QAAHDJbHfWyv3Q1LmnqUoVmf1g9iun29_cluster=testnet

Testing

Related Issues

Closes HOO-380

Checklist

  • My code follows the project's style guidelines
  • I have added tests that prove my fix/feature works
  • All tests pass locally and in CI
  • I have run build:info script to update build information
  • CI/CD checks pass
  • I have included screenshots for protocol screens (if applicable)

@vercel
Copy link

vercel bot commented Mar 24, 2026

@askov is attempting to deploy a commit to the Solana Foundation Team on Vercel.

A member of the Team first needs to authorize it.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 24, 2026

Greptile Summary

This PR introduces a hand-rolled parser and renderer for SPL Token / Token-2022 Batch instructions (discriminator 0xff), filling a gap left by the absence of any published SDK decoder for this wire format. The feature is cleanly isolated in app/features/token-batch/ and integrates into InstructionsSection behind an ErrorBoundary.

Key changes:

  • batch-parser.ts: Walks the packed wire format (u8 num_accounts | u8 data_len | payload…), emitting typed ParsedBatchSubInstruction objects for each sub-instruction.
  • decode-sub-instruction.ts: Uses @solana-program/token-2022 codecs to decode the nine most common base instructions (Transfer, Approve, Mint, Burn, SetAuthority, CloseAccount, and checked variants); Token-2022 extension instructions fall back gracefully to raw hex.
  • const.ts: Discriminator-to-name map covering base instructions (0–24) and Token-2022 extensions (25–45) — though discriminator 42 (TokenMetadataExtension) is currently absent.
  • TokenBatchCard / SubInstructionRow: Display sub-instructions with named account roles, decoded fields, and Writable/Signer badges.
  • Previous round of review feedback is well-addressed: the test-utils file has been moved to __tests__/, the discriminator map expanded significantly, SetAuthority edge cases are handled, and AuthorityType variants are resolved via the SDK enum.

Confidence Score: 5/5

  • Safe to merge; all remaining notes are non-blocking P2 suggestions that can be addressed in follow-up.
  • The implementation is well-structured and thoroughly tested (unit tests cover happy paths, all error branches, and edge cases). Previous reviewer concerns have been resolved. The only open items are a misleading comment and one absent discriminator entry (42), neither of which causes incorrect behaviour — the discriminator gap results in "Unknown" labelling rather than a crash or data corruption.
  • app/features/token-batch/lib/const.ts — discriminator 42 gap warrants a quick one-line follow-up.

Important Files Changed

Filename Overview
app/features/token-batch/lib/batch-parser.ts Core wire-format parser for SPL Token Batch instructions; correctly validates discriminator, reads u8 num_accounts/data_len per sub-instruction, and slices accounts sequentially. Error paths are well-tested and throw descriptive messages.
app/features/token-batch/lib/const.ts Discriminator-to-name map covers base instructions (0–24) and Token-2022 extensions (25–44, 45), but discriminator 42 (TokenMetadataExtension) is absent despite the header comment claiming 25–44 coverage; all other entries look correct.
app/features/token-batch/lib/decode-sub-instruction.ts Decodes common base instructions (Transfer, Approve, Mint, Burn, SetAuthority, CloseAccount and their checked variants) using @solana-program/token-2022 codecs; uncommon/extension types fall back to raw hex gracefully. Minor inaccuracy in the "lazily initialized" comment (decoders are actually eager module-level singletons).
app/features/token-batch/ui/TokenBatchCard.tsx Renders the batch card with an ErrorBoundary in the caller and a local try/catch for parse errors; correctly wraps ix.data in a fresh Uint8Array before parsing. Parse errors surface a visible error message rather than crashing.
app/features/token-batch/ui/SubInstructionRow.tsx Clean component split: decoded instructions get named account labels and field values; unknown/extension types fall back to raw hex with generic Account N labels. Index-based keys on the static account list are appropriate and commented.
app/components/transaction/InstructionsSection.tsx Refactors the instruction dispatcher from a long if/else-if chain to individual early-returns (no behavioural change) and inserts TokenBatchCard behind an ErrorBoundary in the correct position — before Anchor/program-metadata fallbacks.
app/shared/lib/bytes.ts Adds toBuffer helper that wraps a Uint8Array in a Buffer view without copying (shares underlying ArrayBuffer); this shared-memory behaviour is intentional and documented by both a JSDoc comment and a dedicated test.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["InstructionsSection\n(InstructionCard dispatcher)"] -->|isTokenBatchInstruction| B["TokenBatchCard"]
    B --> C["parseBatchInstruction\n(batch-parser.ts)"]
    C -->|reads wire format\nu8 num_accounts · u8 data_len · payload| D["ParsedBatchSubInstruction[]"]
    D --> E["SubInstructionRow × N"]
    E --> F{"decodeSubInstructionParams\n(decode-sub-instruction.ts)"}
    F -->|known base instruction| G["DecodedContent\n(named fields + labeled accounts)"]
    F -->|Unknown / extension| H["RawContent\n(raw hex + Account N labels)"]
    C -->|parse error| I["Error message\n(batch-error)"]
    C -->|empty batch| J["No sub-instructions found\n(batch-empty)"]
Loading

Reviews (2): Last reviewed commit: "fix: Update SPL Token sub-instruction to..." | Re-trigger Greptile

@askov
Copy link
Contributor Author

askov commented Mar 25, 2026

@greptile-apps review please

@vercel
Copy link

vercel bot commented Mar 25, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
explorer Ready Ready Preview, Comment Mar 25, 2026 6:19am

Request Review

throw new Error(`Truncated data: expected num_accounts and data_len at offset ${offset}`);
}
const numAccounts = readU8(data, offset);
const dataLen = readU8(data, offset + 1);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: should we add here a comment noting this is intentional and matches the wire format spec?

@Woody4618
Copy link
Collaborator

Woody4618 commented Mar 25, 2026

This looks wrong to me.
All the amounts show 1 but the token balance changes show 0.00001
image

image

Does the batch transfer also include the mint?

Similar to how the normal transfer works:
image

offset += a.length;
}
return result;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#801 introduces the same functionality to work with bytes here: https://github.com/solana-foundation/explorer/pull/801/changes#diff-4cc2aaeb6c175b24395ee3778bb801815aaf9d2935d6249e9c8d0c77901545d2R131-R154
Let's move this into the shared helper

@rogaldh
Copy link
Contributor

rogaldh commented Mar 25, 2026

Current PR adds support for Batch instructions on the TX page, but Inspector has no conditions to render it properly.
image
image

@Woody4618 do you think we need to support it here, or a separate PR is good?

accounts: AccountEntry[],
): DecodedParams | undefined {
switch (typeName) {
case 'Transfer': {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd suggest to "mimic" structure that does exist for the client already. Check the sample: https://github.com/hoodieshq/explorer/blob/4500073c57aa0a04a45f37f3dfda39cd1a6f1aa0/app/components/inspector/instruction-parsers/spl-token.parser.ts#L3-L11

The client provides separate functions for every case. I think it is beneficial to move the implementation for each type into a separate helper.

};
}

case 'Approve': {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can not see tests for all the decodeByType cases. Might lead to potential degradation.

transferChecked: roles('Source', 'Mint', 'Destination', 'Owner/Delegate'),
};

// Lazily initialized decoders — created once on first use.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: looks like this is not 'lazily' but 'eagerly', right?

39: 'MetadataPointerExtension',
40: 'GroupPointerExtension',
41: 'GroupMemberPointerExtension',
43: 'ScaledUiAmountExtension',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add a comment on why the 42nd is not included.

// Maps SPL Token sub-instruction discriminators to human-readable names.
// Base instructions (0–24, 38, 45):
// https://github.com/solana-program/token/blob/065786e/pinocchio/interface/src/instruction.rs#L9-L551
// Token-2022 extension instructions (25–44):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's exclude 38 here to make the range match to the Base instructions

const typeName = (discriminator !== undefined && typeNameByDiscriminator[discriminator]) || 'Unknown';

if (typeName === 'Unknown') {
Logger.warn('[token-batch] Unknown sub-instruction discriminator', { discriminator, index: subIndex });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WDYT we move Logger usage out of the /lib helper to make it not rely on logger?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants