Start with 3 Free Runs. Get Production-Ready Data Straight to Your Cloud.Get your datasets
Tech

Indexing Solana p-token batches: how Nodit preserves individual token transfers

· 18 min read

This article is based on Zion’s work with Nodit’s Chain Core Development team to implement and validate support for Solana’s p-token upgrade in the Nodit indexer.

TL;DR

  • Solana’s p-token introduces batch operations that require direct decoding to identify individual transfers.
  • Nodit preserves execution order, intermediate account state, and transfer paths when processing batches.
  • Existing event schemas, unique IDs, and retained raw instructions support consistent data delivery and verification.
  • Mainnet validation confirmed 1,711 transfers across 60 batches, with zero missing transfers or field mismatches.
  • This article demonstrates Nodit’s engineering approach to maintaining data accuracy through protocol changes.

Contents

  1. What p-token changes and what stays the same
  2. New p-token instructions: discriminators 38, 45, and 255
  3. How p-token batch instructions are encoded
  4. Why logs and balance changes cannot reconstruct batch operations
  5. How Nodit indexes batch operations
  6. Mainnet validation: 1,711 batch transfers matched
  7. Comparing with another provider: same transaction, different results
  8. Evaluating transfer coverage beyond batch decoding
  9. p-token support checklist for indexer teams
  10. Accessing batch-derived transfers through API and Datashare
  11. Maintaining transfer coverage through protocol upgrades

Earlier posts in this series covered the Solana architecture and account model, followed by decoding blocks, transactions, logs, and instructions by hand, including SPL Token discriminators such as Transfer (3) and TransferChecked (12).

In May 2026, the SPL Token program received a new implementation. The program address, instruction set, and account layout remained unchanged, and wallets and dApps required no modifications.

For teams that index on-chain data, however, the upgrade introduced new indexing requirements.

The official Solana upgrade page for p-token states: "Indexing Changes Required: Yes." Missing data is more difficult to detect than incorrect data, because operations that a pipeline never decodes may not trigger parsing errors or balance discrepancies. Organizations that rely on on-chain data for AML, audits, or reporting should address this gap first.

1. What p-token changes and what stays the same

1a. Why Solana introduced p-token

Token program instructions account for about 10% of the compute units (CU) consumed in a Solana block, according to SIMD-0266. Nearly every activity on the network moves tokens, from DEX swaps to payments to airdrops.

Cheaper token instructions mean more transactions per block, and lower costs for every program that calls the token program through CPI.

CPI (cross-program invocation): one program calling an instruction of another program during execution, via invoke or invoke_signed in Rust.

p-token is a reimplementation of SPL Token built with Pinocchio, a lightweight Rust framework. The "p" stands for Pinocchio. The proposal is SIMD-0266.

1b. Compute usage: SPL Token and p-token compared

cu_spl_vs_ptoken.png

A common guideline has been to use Transfer when compute cost matters and TransferChecked when safety matters. With p-token, the gap between the two dropped from 1,555 CU to 29 CU, and per-instruction CU fell by roughly 8.9x (InitializeMultisig2: 2,826 → 318 CU) to 61x.

1c. New discriminators under the existing program ID

The SPL Token program lives at this address:

TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA

p-token was not deployed to a new address. When the feature gate activated, the runtime swapped the program binary behind the existing address.

ptoken_activation.png

Users, wallets, and dApps did not need to change any code. Existing instructions (0–24 and others) produce the same account-state results.

"Just register the new p-token program ID" does not work.
Transactions executed by p-token are still recorded under Tokenkeg.... What indexers need to add is support for the new discriminators under the existing program ID.

2. New p-token instructions: discriminators 38, 45, and 255

p-token keeps every existing instruction and adds three new ones.

ptoken_new_instructions.png

2a. withdraw_excess_lamports: recovering excess SOL

Every account must hold a rent-exempt deposit proportional to its data size. If someone sent more SOL than that to a mint or multisig account, the legacy token program had no way to recover it. This instruction lets the authority withdraw that previously unrecoverable excess SOL.

The scale is not trivial. The Solana upgrade page cites the USDC mint alone as holding about 323 SOL in excess.

2b. unwrap_lamports: unwrapping wSOL without closing the account

Unwrapping wSOL used to require closing the token account (CloseAccount). unwrap_lamports withdraws only the required lamports and leaves the account open. It has appeared steadily on mainnet since activation.

Both instructions move SOL, not SPL token amounts. (unwrap_lamports withdraws SOL while reducing the wSOL token amount by the same quantity; withdraw_excess_lamports withdraws only the excess SOL left in an account, regardless of any token amount.)
Neither instruction mints or burns tokens, so neither appears in token_mint_burn. SOL movement is reflected in sol_balance_change, and token_balance_change records unwrap_lamports as a net change only when the wSOL token account's balance differs before and after the transaction.

2c. batch: reducing CPI overhead across multiple operations

batch runs several token instructions as a single instruction.

DEXs and aggregators often call the token program several times in one transaction, and every CPI carries a base cost of about 1,000 CU.

Now that p-token has brought most instructions down to double-digit CU, the call overhead costs far more than the work itself. With batch, a program pays that overhead once.

Individual CPI calls vs one batch call

3. How p-token batch instructions are encoded

Batch data is decoded the same way as any other instruction: read the discriminator first, then parse the payload.

3a. Batch payload structure and account allocation

[0]       discriminator = 0xFF (255)
Repeat until the data ends:
  [n]     number_of_accounts (u8)   ← accounts used by this operation
  [n+1]   instruction_data_len (u8) ← length of this operation's data
  [...]   instruction_data          ← this operation's data (with its own discriminator)

There is no field for the number of operations. A decoder keeps reading account count → data length → data until the bytes run out.

Operations do not declare their own accounts either. Each one takes the next slice of the single account list attached to the batch instruction.

3b. Decoding two sequential transfers

The following batch contains two operations: token account A sends 100 to B, and B then sends 40 to C.

Byte layout of the A → B 100, B → C 40 batch

Byte layout of the A → B 100, B → C 40 batch

Each operation uses exactly the same format as a standalone SPL Token Transfer: a 1-byte discriminator plus a u64 little-endian amount. A batch is a wrapper that combines familiar instructions under an account-count and length header.

4. Why logs and balance changes cannot reconstruct batch operations

Indexers typically reconstruct a transaction from signatures, logs, and instructions, then check results against pre/post balances. For batch instructions, none of these sources is sufficient.

4a. Batch operations do not appear as separate inner instructions

When one program calls another through CPI, the call is recorded in meta.innerInstructions. Operations inside a batch are not CPIs. p-token runs them in sequence inside its own program, so the RPC response shows a single batch instruction.

What the RPC response shows:
  Tokenkeg...  batch (data: "4N9VHWi2EKF2..."  ← 42 transfers inside)

What it does not show:
  Transfer #0, Transfer #1, ..., Transfer #41

4b. Instruction-name logs are unavailable

The legacy program emitted logs such as Program log: Instruction: Transfer. p-token skips them, and this applies to every p-token instruction, including a plain Transfer, not just batch. Logging a single instruction name costs more than 100 CU, more than an entire 76 CU Transfer.

Any system that identifies token events by parsing log strings may have started dropping data after p-token, without any error.

4c. Net balance changes do not preserve transfer paths or order

The same A → B 100, B → C 40 example, expressed as balance changes:

Net token balance change (postTokenBalances - preTokenBalances)
  A: -100
  B:  +60
  C:  +40

From these numbers alone, the two scenarios below are indistinguishable:

Scenario 1 (actual):  A → B 100,  B → C 40
Scenario 2:           A → B 60,   A → C 40

The fact that B received 100 first and then passed 40 on, that is, the path and the order, is lost. For fund-flow tracing, compliance, and transaction histories, which depend on identifying the sender and recipient of each transfer, this is a critical gap.

batch_signals.png

Recovering individual operations requires decoding the batch payload.

rpc_vs_nodit_side_by_side.png

5. How Nodit indexes batch operations

The Nodit Solana indexer pulls every block with getBlock and decodes each transaction and instruction.

The output becomes domain events: token transfers (token_transfer), mints and burns (token_mint_burn), authority changes (authority_change), and account closures (account_close).

Supporting batch came down to four design decisions.

5a. Mapping batch operations to existing event tables

One option was to add a separate table such as batch_transfer. That approach would require every API user and analyst to merge two sources when querying the transfer history of an account.

Instead, the indexer treats each operation inside a batch as if it were a standalone instruction. A Transfer inside a batch lands in token_transfer, exactly like any other Transfer. Consumers never need to know whether a batch was involved.

The implementation reuses the existing instruction handlers: decode the batch, build a synthetic instruction for each operation, and pass it to the existing handlers.

// Simplified
batch, err := decoder.DecodeSPLTokenBatchInstruction(instruction)
if err != nil {
    return nil, err // Truncated data, missing accounts, etc.: reject the whole batch (no partial loads)
}

for opIndex, op := range batch.Operations {
    // Inherit the coordinates of the parent batch (slot, tx, instruction index...)
    // and swap in only the accounts and data of this operation
    synthetic := newSyntheticInstruction(instruction, op)

    // Reuse the existing Transfer / MintTo / Burn / SetAuthority / CloseAccount handlers
    events, err := handlerFor(synthetic).Handle(ctx, synthetic, accountState)
    if err != nil {
        return nil, err
    }
    for _, e := range events {
        e.SetBatchOperation(uint64(opIndex)) // Record the position of this operation
        results = append(results, e)
    }
}

No new Transfer decoder was needed. Because the existing handlers are reused, a Transfer inside a batch and a regular Transfer go through exactly the same logic.

5b. Preserving execution order and intermediate account state

Operations inside a batch affect each other. A batch like this is valid:

Op 0: InitializeAccount  (initialize new token account D, owner = X)
Op 1: Transfer           (C → D)
Op 2: SetAuthority       (change owner of D from X to Y)
Op 3: Transfer           (D → E)

In op 1 the recipient is X; in op 3 the sender is Y. The final state after the transaction (postTokenBalances) shows Y as the owner of D. Relying on that value alone would record Y as the recipient in op 1, which is wrong.

So the indexer always processes operations in order, and each operation inherits the account state (mint and owner) left by the previous one.

5c. Assigning unique event IDs with extra_index

The indexer assigns a unique ID to every event derived from an instruction. Until now, that ID was built from the coordinates of the instruction:

{slot}-{transaction_index}-{instruction_index}-{sub_instruction_index}

Operations unpacked from a batch all come from the same batch instruction, so they share the same coordinates.

Unpacking a batch with 42 Transfers would produce the same ID 42 times. The database either overwrites the first 41 rows or throws a collision error.

To resolve this, Nodit added a fifth component to the ID:

Regular events:        {slot}-{tx}-{ix}-{sub_ix}                (unchanged)
Batch-derived events:  {slot}-{tx}-{ix}-{sub_ix}-{extra_index}  (e.g. ...-0041)

The format of extra_index reflects the following design choices:

  • Regular events do not receive one. None of the billions of existing IDs change.
  • It is a zero-padded, 4-digit string (0000, 0001, …), so string sorting matches numeric order. An instruction can reference at most 255 accounts and every operation uses at least one, so a batch can never exceed 255 operations.
  • It is a string, not a number. If a future Solana update adds another layer inside instructions, it can be extended as 0042_0007 without changing the schema type.

5d. Retaining raw instructions for verification and backfill

Alongside the unpacked events, the indexer stores the original batch instruction as a raw instruction row. This allows Nodit to backfill by re-decoding stored data, without calling RPC again, for the period between p-token activation (slot 419,472,000) and the deployment of batch support.

A few more rules:

  • Batches in failed transactions are not unpacked, because a failed transaction reverts every state change.
  • Malformed batches (truncated data, missing accounts) are rejected entirely. Partially loaded data is harder to catch than wrong data.
  • p-token does not allow nested batches, so a guard prevents infinite recursion.
How the Nodit indexer processes a batch

6. Mainnet validation: 1,711 batch transfers matched

Correct unpacking must be verified on real data, and batches proved to be rare on mainnet. A direct RPC scan in June 2026 found zero batches in the 6,000 consecutive blocks after activation.

The team then sampled roughly 4 million slots after activation at regular intervals before finding the first real batch. Over the same period, unwrap_lamports appeared steadily at about 7 per 30 blocks.

6a. Batch usage across 2,000 sampled mainnet blocks

Usage was re-measured on data loaded in October 2026. The team sampled 2,000 blocks at regular intervals between October 3 and 13 and counted the discriminator of every token program instruction (outer and inner).

new_ptoken_instructions_oct2026.png
  • 76 of the 90 (84%) came from failed transactions. Since the indexer skips those, batches that actually produce token events show up roughly once every 140 blocks.
  • That is about 1 batch per 18,000 token program instructions. Only 40 of the 2,000 blocks (2%) contained a batch.
  • All 90 were called via CPI by other programs, and 86 came from a single program. Every one carried the same payload: two transfers.
  • The most recent 1,000 consecutive blocks (October 13) held 9 batches, all in failed transactions.
  • withdraw_excess_lamports, absent in June, now appears about as often as unwrap_lamports.

For now, batch looks like a feature used by a handful of programs.

Low frequency does not make batch support optional. Without support, every token movement in those transactions disappears, and as the transaction below shows, a few batches can carry more than 1,700 transfers. Usage can change the moment any major program adopts batch.

6b. Validation transaction: 60 batches and 1,711 transfers

The validation below uses the first batch transaction identified in June.

validation_tx.png

In this transaction, a single owner moves tokens through a chain of its own token accounts. 1,710 of the 1,711 transfers stay between accounts of that owner; the last one goes to a different owner (CE1GsXmX…). The first batch unpacks to:

Op 0:  CSktDphi… → 5wbZXpKr…  amount 1
Op 1:  5wbZXpKr… → DCmTwTcn…  amount 2
Op 2:  DCmTwTcn… → 6Cow3YiG…  amount 3
…
Op 41: 6w6j6RK3… → AMVZXFGF…  amount 42

The recipient of each transfer becomes the sender of the next. Looking at the first batch on its own, net balance changes show only -1 on the first account, +42 on the last, and -1 on each account in between. Across the whole transaction, 1,711 transfers collapse into net changes on 58 accounts.

The fact that 42 transfers happened in sequence is visible only after decoding the batch.

chain_vs_net_balance.png

6c. Validation results: transfer counts, field values, and unique IDs

The same slot was indexed before and after batch support was added:

token_transfer_before_after.png

The block was then fetched from RPC again and compared 1:1 with values decoded independently of the indexer:

Independently decoded transfers (RPC) : 1,711
Transfers loaded by the indexer       : 1,711

Missing          : 0
Extra            : 0
Field mismatch   : 0   (sender, recipient, amount all match)
Duplicate IDs    : 0   (2,487 / 2,487 unique)
  • All 1,711 operations inside the batches landed in token_transfer accurately, with nothing missing.
  • The 776 existing non-batch transfers and all other data (mints and burns, account closures, raw instructions) kept the same event counts in the tested slot.
  • A full rescan of the slot confirmed that all 60 batch instructions were processed.

7. Comparing with another provider: same transaction, different results

The validation transaction above was queried through the Nodit API and through two APIs from another provider (Provider A), and the results were compared to see how each API returned the transfers inside the batches.

7a. Test transaction

test_tx.png

Ground truth from raw decoding

The inner instruction data in the RPC getTransaction response was decoded directly and used as the reference.

  • All 60 inner instructions under outer instruction #2 are p-token batch (discriminator 255)
  • Operations per batch: 42 in 30 batches, 15 in 29 batches, 16 in 1 batch → 1,711 Transfers
  • 1,710 same-owner movements: between token accounts owned by 8h9Tg5ni… (the first batch is a chain with amounts 1, 2, …, 42)
  • 1 transfer to a different owner: 16th operation of the 60th batch, 8h9Tg5ni… → CE1GsXmX…, amount 1711
  • Zero Instruction: log lines

7b. Summary

api_response_compare.png

7c. Requests (API keys omitted)

# Nodit: 100 per page, 18 calls with cursor
POST <https://web3.nodit.io/v1/solana/mainnet/token/getTokenTransfersByAccount>
X-API-KEY: <NODIT_API_KEY>
{"accountAddress":"8h9Tg5niW5KstwbQqQbSgD7WPpcW2rA9bTBd9y2qTu8t",
 "fromSlot":420464250,"toSlot":420464250,"rpp":100,"cursor":"<cursor from previous response>"}

# Provider A transfer API: queried with both owner addresses, same result
POST <https://mainnet.providerA-rpc.com/?api-key=><A_API_KEY>
{"jsonrpc":"2.0","id":1,"method":"getTransfersByAddress",
 "params":["8h9Tg5niW5KstwbQqQbSgD7WPpcW2rA9bTBd9y2qTu8t",
   {"limit":100,"filters":{"slot":{"gte":420464250,"lte":420464250}}}]}

# Provider A transaction parsing API
POST <https://api-mainnet.providerA-rpc.com/transactions?api-key=><A_API_KEY>
{"transactions":["2eptQSr24xbGbVjdnyjCC1p9qF6HUKj8znCvc3zAgC6eoMSeYUzoLH7LQ6RR19gh8CFULy32oWesUZua2GLXCKu6"]}

7d. Responses (abridged)

Nodit: 1,711 transfers (common fields omitted, 4 shown)

Common fields (same for all 4): slotHeight 420464250, transactionIndex 1130, instructionIndex 2, programIdTokenkeg…, instructionType "transfer", mintAddress EqUoWB2u…

// First batch, op 1 (same-owner movement, start of the chain)
{ "subInstructionIndex": 1, "batchIndex": 0,
  "from": "8h9Tg5niW5KstwbQqQbSgD7WPpcW2rA9bTBd9y2qTu8t", "fromTokenAccount": "CSktDphihzuV4xQDcvaM7CdQhbf86zYCFcXXwpYh7NGE",
  "to":   "8h9Tg5niW5KstwbQqQbSgD7WPpcW2rA9bTBd9y2qTu8t", "toTokenAccount":   "5wbZXpKrfZpR6h34pWz4xfkUqophu3QTPpnBUoRZWZXY",
  "value": "1", "decimals": 9 }
// First batch, op 2 (the previous recipient becomes the sender)
{ "subInstructionIndex": 1, "batchIndex": 1,
  "from": "8h9Tg5niW5KstwbQqQbSgD7WPpcW2rA9bTBd9y2qTu8t", "fromTokenAccount": "5wbZXpKrfZpR6h34pWz4xfkUqophu3QTPpnBUoRZWZXY",
  "to":   "8h9Tg5niW5KstwbQqQbSgD7WPpcW2rA9bTBd9y2qTu8t", "toTokenAccount":   "DCmTwTcnyexohghtfQNPjC8YMkg4WsRUsZfvFzuvadEV",
  "value": "2", "decimals": 9 }
// … (1,707 omitted)
// First batch, last op
{ "subInstructionIndex": 1, "batchIndex": 41,
  "from": "8h9Tg5niW5KstwbQqQbSgD7WPpcW2rA9bTBd9y2qTu8t", "fromTokenAccount": "6w6j6RK3cHupetUUmB9vkFMhHhdpR1vsAbnrc67zPAYX",
  "to":   "8h9Tg5niW5KstwbQqQbSgD7WPpcW2rA9bTBd9y2qTu8t", "toTokenAccount":   "AMVZXFGFFMiXFXFsN8s2N2xVWfAcBLqDJNeC7M3Axudb",
  "value": "42", "decimals": 9 }
// 60th batch, op 16 (the only transfer to a different owner)
{ "subInstructionIndex": 60, "batchIndex": 15,
  "from": "8h9Tg5niW5KstwbQqQbSgD7WPpcW2rA9bTBd9y2qTu8t", "fromTokenAccount": "CSktDphihzuV4xQDcvaM7CdQhbf86zYCFcXXwpYh7NGE",
  "to":   "CE1GsXmXNtN94vb9ETJjp8xMzDowHhDBP53tahCaSHnk", "toTokenAccount":   "AkbcJ3cF56yqk5zfPHLwTrYixuzt3mwvCrUto2YQ5EzM",
  "value": "1711", "decimals": 9 }

Provider A transfer API: 1 transfer (full response)

{ "data": [{
    "signature": "2eptQSr24xbGbVjdnyjCC1p9qF6HUKj8znCvc3zAgC6eoMSeYUzoLH7LQ6RR19gh8CFULy32oWesUZua2GLXCKu6",
    "slot": 420464250, "blockTime": 1779071819, "type": "transfer",
    "fromUserAccount": "8h9Tg5niW5KstwbQqQbSgD7WPpcW2rA9bTBd9y2qTu8t",
    "toUserAccount":   "CE1GsXmXNtN94vb9ETJjp8xMzDowHhDBP53tahCaSHnk",
    "fromTokenAccount": "CSktDphihzuV4xQDcvaM7CdQhbf86zYCFcXXwpYh7NGE",
    "toTokenAccount":   "AkbcJ3cF56yqk5zfPHLwTrYixuzt3mwvCrUto2YQ5EzM",
    "mint": "EqUoWB2umVoJzGQfe2nikceRqEgZw8FoDwS6AtMv8it8",
    "amount": "1711", "decimals": 9, "uiAmount": "0.000001711",
    "confirmationStatus": "finalized",
    "transactionIdx": 1130, "instructionIdx": 2, "innerInstructionIdx": 60, "batchInstructionIdx": 16 }],
  "paginationToken": null }

Provider A transaction parsing API: 0 transfers, 58 balance changes

{ "signature": "2eptQSr24xbGbVjdnyjCC1p9qF6HUKj8znCvc3zAgC6eoMSeYUzoLH7LQ6RR19gh8CFULy32oWesUZua2GLXCKu6",
  "type": "UNKNOWN", "source": "UNKNOWN", "description": "",
  "feePayer": "CE1GsXmXNtN94vb9ETJjp8xMzDowHhDBP53tahCaSHnk", "fee": 19000,
  "tokenTransfers": [], "nativeTransfers": [], "events": {},
  "accountData": [   // 58 of 63 accounts have tokenBalanceChanges (1 receiving + 57 sending)
    { "account": "AkbcJ3cF56yqk5zfPHLwTrYixuzt3mwvCrUto2YQ5EzM",
      "tokenBalanceChanges": [{ "userAccount": "CE1GsXmXNtN94vb9ETJjp8xMzDowHhDBP53tahCaSHnk",
        "rawTokenAmount": { "tokenAmount": "1711", "decimals": 9 } }] },
    { "account": "12wSVYh4Cfus8KyTzMFoGv7LiC8FnA5cwYxf4HEH3y23",
      "tokenBalanceChanges": [{ "userAccount": "8h9Tg5niW5KstwbQqQbSgD7WPpcW2rA9bTBd9y2qTu8t",
        "rawTokenAmount": { "tokenAmount": "-30", "decimals": 9 } }] }
    // … 56 omitted
  ],
  "instructions": [
    { "programId": "ComputeBudget111111111111111111111111111111" },
    { "programId": "ComputeBudget111111111111111111111111111111" },
    { "programId": "5jusGBn1MPvrs4SfCJgQiZXQkAxriKMEKwPvpAidPiC8",
      "innerInstructions": [   // all 60 are Tokenkeg batch, left as raw base58
        { "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "data": "4N9VHWi2EKF2…", "accounts": ["… 126"] } ] } ] }

7e. What the comparison shows

The three APIs returned clearly different results for the same transaction on two criteria.

  • Does it recognize operations inside a batch? Nodit and Provider A's transfer API both identified each operation's position inside the batch. Provider A's transaction parsing API returned the batch as raw, undecoded data.
  • Does it keep the path? Provider A's transfer API dropped the 1,710 same-owner movements and returned only the one transfer to a different owner. Nodit returned all 1,711, including that one, in execution order.

Provider A's data shows where the tokens ended up. Nodit's data also shows every account the tokens passed through, and in what order.

8. Evaluating transfer coverage beyond batch decoding

Supporting batch is only the first requirement. For financial institutions and virtual asset service providers, the question that matters is what the provider keeps after unpacking it.

That choice decides whether AML monitoring, audit response, and internal reporting can rely on reproducible evidence. Two providers can decode the same batch and still end up with different data, depending on what their data model was built to answer.

data_models_compare.png

Movement-level data records every token movement on-chain, one row per movement, in execution order. It shows not only how much a balance changed, but the path that produced the change. In this post, self-transfers mean transfers between different token accounts controlled by the same owner.

The validation transaction above illustrates this. A single owner moved tokens through 1,710 chained transfers across its own token accounts, then sent one transfer to a different owner. A balance view reduces all 1,711 transfers to net changes on 58 accounts. Provider A's transfer API, which excludes same-owner movements, returns only that one outgoing transfer (see section 7 above). Balances preserve net changes, but they do not establish the transfer path or order. Nodit keeps all 1,711 transfers, in execution order.

8a. Why intermediate transfers matter for tracing and audits

workflows_net_vs_movement.png

9. p-token support checklist for indexer teams

Teams that run their own indexer or analyze RPC data directly should check the following:

ptoken_checklist.png

Users do not need to build this capability themselves.

The Nodit indexer already unpacks and indexes token transfers, including those inside batches. Through the Nodit Solana Data API, a query for the token transfer history of an account returns transfers inside batches in the same format as regular transfers. The Nodit Solana Data API is live today, along with gRPC streaming for real-time delivery.

Learn more: Solana Data API and gRPC Stream Are Now Live on Nodit

10. Accessing batch-derived transfers through API and Datashare

Batch events unpacked by the indexer are delivered two ways. Both come from the same indexer output, so the transfer history returned by the API matches the records in the dataset.

api_vs_datashare.png

10a. Batch-derived transfer records in Datashare

The first batch of the test transaction appears in the Datashare token transfer table one row per transfer. extra_indexkeeps rows in batch order, and IDs of existing events stay the same.

datashare_rows.png

Compliance teams can join this table directly with internal customer and transaction records. There is no separate pipeline to build, and Nodit maintains the decoding pipeline.

All batch-derived events are loaded into the Nodit data warehouse. extra_index is supported across eight datasets, not just transfers: token_transfer, token_mint_burn, account_close, authority_change, token_2022_metadata, token_meta, token_metadata_pointer_update, and token_metadata_update.

10b. Comparing batch-transfer coverage across datasets

dataset_comparison.png

All three datasets support warehouse delivery, but only one preserves the full record of p-token batch activity. In Nodit's validation test, Nodit Datashare was the only dataset to return the individual transfers executed inside batch instructions, each with an extra_index that preserves execution order. For teams that rely on this data for AML monitoring, audits, or regulatory reporting, that difference determines whether transaction histories can be fully reconstructed.

11. Maintaining transfer coverage through protocol upgrades

Balance snapshots show the result, while instructions show the cause. p-token batch demonstrates that the cause can be compressed into a single instruction.

Working with blockchain data requires identifying, with every protocol change, what has become invisible and what has been added, and updating the pipeline accordingly.

Solana continues to introduce SIMDs to improve performance and expand the ecosystem. SIMD-0266 is one of them.

Keeping up with those changes is a job for the data infrastructure, not for the teams that use the data. When a change like batch compresses activity into a single instruction, Nodit’s users keep receiving supported token movements through the same API and the same tables. For data used in AML, audits, and reporting, this continuity supports stable operations.

Nodit treats every Solana protocol upgrade as a data integrity event. As with p-token, Nodit assesses each change, updates the decoding pipeline, and validates the output against mainnet data before it reaches customers.


FAQ

1. What is p-token on Solana?

p-token is the Optimized Token Program defined in SIMD-0266: a reimplementation of the SPL Token program built with the Pinocchio framework. It went live on Solana mainnet on May 13, 2026 (epoch 971), keeps the same program ID, and cuts compute usage for common instructions such as Transfer by roughly 95–98%.

2. Did p-token change the SPL Token program ID?

No. p-token replaced the program binary behind TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA. Indexers need to recognize the new discriminators (38, 45, 255) under the existing program ID.

3. Why don't batch operations appear as separate inner instructions?

batch (discriminator 255) runs several token instructions inside one instruction, so a calling program pays the roughly 1,000 CU CPI overhead only once. The operations inside are executed by p-token itself, not through CPI, and p-token emits no instruction logs. Recovering individual operations requires decoding the batch payload.

4. Does the Nodit Solana Data API include transfers inside p-token batches?

Yes. Every supported transfer operation inside a successful batch is stored as a standard token transfer, in execution order, and returned in the same format as any other transfer. Other operations, such as mints, burns, authority changes, and account closures, map to their own event types. Each one carries a unique ID with an extra_index suffix that preserves its position in the batch.

5. Can p-token batch transfers be delivered to a data warehouse?

Yes. Nodit Datashare delivers the same token transfer data as the API, as clean tables in the user's data warehouse, including intermediate hops between token accounts.

References


About Nodit

Nodit is an enterprise-grade blockchain infrastructure platform providing reliable node access and consistent on-chain data for digital asset services. Across 70+ networks, Nodit combines managed node infrastructure, standardized data processing and delivery, Datashare with specialized blockchain datasets, and Validator-as-a-Service (VaaS) to support production-scale operations, institutional analytics, and AI applications.

Backed by SOC 2 Type II and proven experience with major regulated exchanges worldwide, Nodit provides the complete on-chain data pipeline that powers the digital asset economy.

Homepage l X (Twitter) l LinkedIn