Skip to main content

Extract Transfers from a Solana Block

This page shows how to pull transfers out of a Solana block read from the data lake. It builds on the streaming and decoding steps from the overview. For Ethereum, Base and other EVM chains, see Extract Transfers from an EVM Block.

Where transfers live in a Solana block​

Solana has no event logs like EVM Transfer events. A transfer is an instruction that a program executes. Two programs cover almost all of them:

  1. Native SOL. The System Program (11111111111111111111111111111111) moves lamports with its Transfer instruction.
  2. SPL tokens. The Token Program (TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA) and Token-2022 (TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb) move tokens with Transfer and TransferChecked.

In the decoded BlockMessage from the solana module of bitquery-pb2-kafka-package:

  • block.Transactions holds every transaction. tx.Status.Success tells you if it landed. Failed transactions move nothing, so skip them.
  • tx.Header.Accounts is the transaction's account list. Instructions point into it by index.
  • tx.Instructions is a flat list of top-level and inner instructions. Inner instructions are the cross-program calls (CPI) that a DEX or aggregator makes, so swap legs are in this list too. NestingLevel and CallerIndex tell you where each one sits.
  • Each instruction has ProgramAccountIndex (the program that ran), AccountIndexes (the accounts it used) and Data (the raw instruction bytes).

A note on bytes: addresses, signatures and Data are raw bytes in the decoded object. Solana shows addresses and signatures as base58 strings, so the parser includes a small base58 encoder.

How the instructions are shaped​

InstructionProgramData layoutAccounts
TransferSystemu32 index 2, then u64 lamports[from, to]
TransferToken / Token-2022u8 3, then u64 amount[source, destination, authority]
TransferCheckedToken / Token-2022u8 12, then u64 amount, then u8 decimals[source, mint, destination, authority]

All integers are little-endian.

SPL tokens are not held by wallets directly. They sit in token accounts, and each token account has an owner wallet and a mint. The block already carries this for you: for every token account in tx.Header.Accounts, Account.Token holds the Mint, the Owner wallet and the Decimals. So you get the wallet-to-wallet view and the token decimals without extra lookups.

The parser​

from solana.block_message_pb2 import BlockMessage

B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"

def b58(raw):
# Solana addresses and signatures are base58 strings of raw bytes
n, s = int.from_bytes(raw, "big"), ""
while n:
n, r = divmod(n, 58)
s = B58[r] + s
return "1" * (len(raw) - len(raw.lstrip(b"\0"))) + s

SYSTEM_PROGRAM = "11111111111111111111111111111111"
TOKEN_PROGRAMS = {
"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", # SPL Token
"TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", # Token-2022
}

def u64(data, offset):
return int.from_bytes(data[offset:offset + 8], "little")

def transfers_from_block(block):
out = []
for tx in block.Transactions:
if not tx.Status.Success: # failed transactions move nothing
continue
sig = b58(tx.Signature)
accounts = tx.Header.Accounts
for ix in tx.Instructions: # top-level and inner (CPI) instructions
program = b58(accounts[ix.ProgramAccountIndex].Address)
data, idx = ix.Data, ix.AccountIndexes

# 1. native SOL: System Program Transfer, u32 index 2 + u64 lamports
if program == SYSTEM_PROGRAM:
if len(data) >= 12 and int.from_bytes(data[:4], "little") == 2:
out.append({
"type": "sol", "tx": sig, "mint": None,
"from": b58(accounts[idx[0]].Address),
"to": b58(accounts[idx[1]].Address),
"amount": u64(data, 4), "decimals": 9,
})

# 2. SPL tokens: Transfer (3) and TransferChecked (12)
elif program in TOKEN_PROGRAMS and data:
if data[0] == 3 and len(data) >= 9: # source, dest, authority
src, dst = accounts[idx[0]], accounts[idx[1]]
elif data[0] == 12 and len(data) >= 10: # source, mint, dest, authority
src, dst = accounts[idx[0]], accounts[idx[2]]
else:
continue
token = src.Token if src.Token.Mint else dst.Token
out.append({
"type": "spl", "tx": sig, "mint": b58(token.Mint),
# token accounts hold the balance; Token.Owner is the wallet
"from": b58(src.Token.Owner or src.Address),
"to": b58(dst.Token.Owner or dst.Address),
"amount": u64(data, 1), "decimals": token.Decimals,
})
return out

Run it​

Save the parser as solana_transfers.py, and this runner next to it as extract.py:

import sys, collections, lz4.frame
from solana_transfers import transfers_from_block, BlockMessage

path = sys.argv[1]
mint = sys.argv[2] if len(sys.argv) > 2 else None

block = BlockMessage()
block.ParseFromString(lz4.frame.decompress(open(path, "rb").read()))

transfers = transfers_from_block(block)
counts = collections.Counter(t["type"] for t in transfers)
print(f"slot {block.Header.Slot}: {len(transfers)} transfers {dict(counts)}\n")

if mint:
transfers = [t for t in transfers if t["mint"] == mint]
for t in transfers[:3]:
human = t["amount"] / 10 ** t["decimals"]
print(f"[{t['type']}] {t['mint'] or 'SOL'} {t['from']} -> {t['to']} amount {t['amount']} ({human:g})")

Pass it a Solana .block.lz4 file streamed from the lake. Add a mint address to see only that token:

# all transfers, print a breakdown and the first 3
python extract.py 000457030560_9WrSdMdyUBjf6NewzfntaKBDwZRmg7EuEW6WPPXwnx5P_<...>.block.lz4

# only USDC
python extract.py 000457030560_9WrSdMdyUBjf6NewzfntaKBDwZRmg7EuEW6WPPXwnx5P_<...>.block.lz4 EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v

Against Solana slot 457,030,560 this is what comes back:

slot 457030560: 1395 transfers {'sol': 194, 'spl': 1201}

[sol] SOL AxiomRXZAq1Jgjj9pHmNqVP7Lhu67wLXZJZbaK87TTSk -> 31LuZVPmU3G8RUUJgdTjwtEwP5KqF82sRgxtyEBQWk3M amount 67357050 (0.0673571)
[sol] SOL stnkwFt5Ao6Kq9QqfuLchMHJDxBSD6sqeJfiqM3aAKr -> 4TQLFNWK8AovT1gFvda5jfw2oJeRMKEmw7aH6MGBJ3or amount 1000000 (0.001)
[sol] SOL H4vdB2pcMhzyVgEqMXwboRTebTtQGSN8nK4CANBq1Zck -> 3fVJdx56wq5CNRPRttFUGpZi3FNfPzhjStLeopzXYQRU amount 650240 (0.00065024)

And with the USDC filter:

[spl] EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v 9JBv6vySuNHniJfRpDMd3xnnAqRb973ks4U7T9ac3Be -> 8ekCy2jHHUbW2yeNGFWYJT9Hm9FW7SvZcZK66dSZCDiF amount 2538506 (2.53851)
[spl] EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v 9JBv6vySuNHniJfRpDMd3xnnAqRb973ks4U7T9ac3Be -> R4rNJHaffSUotNmqSKNEfDcJE8A7zJUkaoM5Jkd7cYX amount 100000 (0.1)
[spl] EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v 9JBv6vySuNHniJfRpDMd3xnnAqRb973ks4U7T9ac3Be -> AhGsEjoU6mNqNB64XsdARDvCmwmt3scsHU7b5WL7CeKQ amount 0 (0)

The slot holds 1,860 transactions, and 1,576 of them succeeded. Among those, the parser finds 1,395 transfers: 194 native SOL and 1,201 SPL token transfers. Most SPL transfers are wrapped SOL (So11111111111111111111111111111111111111112), which DEX swaps move as a token.

To call the function yourself on a decoded block:

from solana_transfers import transfers_from_block

transfers = transfers_from_block(block) # block is a decoded solana BlockMessage

Turning raw amounts into human values​

amount is the raw integer in the token's smallest unit. Divide by 10 ** decimals to get a human value. Unlike EVM blocks, a Solana block carries the decimals: Account.Token.Decimals for SPL tokens, and SOL always has 9 (1 SOL = 109 lamports). So 67357050 lamports is about 0.0674 SOL, and 2538506 USDC units with 6 decimals is about 2.54 USDC.

Extending the parser​

  • Balance changes instead of instructions. Each instruction also carries BalanceUpdates (SOL) and TokenBalanceUpdates (SPL), with the pre and post balance per account. Use them to catch value moved by programs that do not call the Token Program the usual way, or to check your transfer totals.
  • More System Program moves. CreateAccount (index 0) and TransferWithSeed (index 11) also move lamports. Add them if you want rent deposits and seeded transfers.
  • Mints and burns. MintTo (7), MintToChecked (14), Burn (8) and BurnChecked (15) change token supply. They use the same account and Data pattern as above.
  • Token-2022 extensions. Tokens with a transfer fee use TransferCheckedWithFee from the transfer fee extension. Add it if you track Token-2022 tokens with fees.
  • Token accounts without owner info. If a token account has no Token data, the parser falls back to the token account address. You can resolve its owner later from another transaction that touches it.

The lake gives you the full block, and the protobuf schema describes every field, so a Solana transfer extractor is a short pass over the instructions.

If you want parsed Solana transfers datasets without writing a parser, including history, they are available as Data Store exports.

Build with Bitquery

Ready to run this in production?

Get an API key and run these queries in minutes, or talk to us about plans and enterprise delivery.