How to Monitor SOL and SPL Token Transfers for Thousands of Wallets
Track SOL and SPL transfers for thousands of Solana wallets. Covers the ATA gap, Yellowstone gRPC account filters, scale tiers to 10,000+, and fan-out delivery for multi-user trackers.
On this page +
Your wallet address is not where your tokens live.
SPL tokens sit in token accounts. One per mint, derived by the token program. Different address entirely. Subscribe to a wallet address and you catch SOL transfers. That's the full list. Everything else is silence.
We've debugged enough wallet trackers to recognize this pattern in the first five minutes. Events coming in, filter looks correct, SOL moves fine. Token transfers: nothing. The subscription isn't broken. It's watching the wrong address.
01SOL Transfers and SPL Transfers Are Not the Same Thing#
SOL transfers go through the System Program: 11111111111111111111111111111111. Nothing else. A transfer instruction carries discriminator byte 2, and balance changes show up in preBalances and postBalances on the transaction meta. One entry per account in accountKeys, indexed by position.
Token transfers are different. Completely different program, different account model, different balance tracking. The Token Program (TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA) processes SPL token moves. It uses Transfer (discriminator byte 3) and TransferChecked (discriminator byte 12). Instead of preBalances/postBalances, token amounts live in preTokenBalances and postTokenBalances.
Token-2022 adds a third program: TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb. Same instruction interface. Different address. Newer tokens use it: some stablecoins, wrapped assets, tokens with transfer fees or interest-bearing mechanics.
That's three programs. System Program for SOL, Token Program for most SPL tokens, Token-2022 for the rest. A complete wallet tracker needs subscriptions to all three, separate parsers for each balance format, and handling for the case where a single transaction touches both SOL and token balances at once. A swap that deducts a fee in lamports alongside the token movement triggers changes in both balance arrays in the same event.
Most builds start with just the wallet address. That covers SOL. The rest is why you're reading this.
02Why Monitoring a Solana Wallet Address Misses Token Transfers#
Your wallet address is an ed25519 keypair public key. It holds SOL directly. The runtime tracks SOL balances and the System Program moves them. Tokens work differently.
When you hold USDC, you don't hold it at your wallet address. You hold it at an associated token account. That's a program-derived address, calculated from three seeds: your wallet pubkey, the token program ID, and the mint address. The Associated Token Program (ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJe1bk) derives the address deterministically. One ATA per wallet per mint. A wallet holding USDC, BONK, and JUP has at least three ATAs at three different addresses.
The ATA's account data has a clear layout: mint (which token), owner (your wallet address), amount (current raw balance). The owner field is where your wallet address appears. Not in the ATA's address itself.
Here's why this breaks wallet subscriptions.
When you set account_include: ["ABC...yourwallet"] in a Yellowstone gRPC filter, you're telling it: deliver transactions where ABC...yourwallet appears in accountKeys. A SOL transfer includes your wallet in accountKeys. Fine.
A USDC transfer touches your USDC token account. Your wallet address doesn't appear in accountKeys for that transaction. It doesn't match the filter. It never arrives.
The fix has two paths.
Path A: subscribe to the ATAs directly. Resolve each wallet's token accounts, add them to account_include alongside the wallet address. More targeted, lower volume. But you'll need to refresh when a wallet receives a new token for the first time, because the new ATA wasn't in your filter.
Path B: use postTokenBalances. The practical default. Every transaction Yellowstone delivers includes a postTokenBalances array regardless of which account triggered the filter match. Each entry has an owner field: that's the wallet address. Walk the array, match owner against your watchlist. No ATA resolution needed. Works for tokens the wallet has never held before.
The rest of this article uses Path B throughout.
03Under 500 Wallets: RPC Polling and WebSocket for Solana Wallet Monitoring#
For small watchlists, RPC polling with getSignaturesForAddress is simple and it works. You're not fighting connection limits or stream management.
Call it for each wallet, pull the last N signatures, fetch new transactions. One wallet at a 1-second interval is two requests per minute. Totally manageable.
The math turns against you quickly. Two hundred wallets at a 6-second poll interval is 33 requests per second at baseline. That assumes instant responses and zero retries. In practice, burst is worse, because you're not polling all wallets simultaneously. You're batching them, and a slow response at the front of the batch delays everything behind it. Public Solana RPC nodes rate-limit at 40–100 req/s depending on the method. You'll hit it around 150 wallets under normal conditions, sooner during volatile markets when you need faster intervals.
WebSocket accountSubscribe looks like the solution. Push-based, no polling, one connection per account. Two problems.
First: it delivers account state snapshots. When a SOL balance changes, you get the new balance. You don't get the transaction that caused it, the instruction data, the counterparty, or token balance changes. For anything beyond "did this balance change," you still need to fetch the transaction separately.
Second: for SPL tokens, you'd need to subscribe to the token accounts, not the wallet. Back to ATA resolution. And WebSocket connections have a practical ceiling around 50–100 concurrent subscriptions before clients start dropping events.
Under 50 wallets, RPC polling with a generous interval is fine. Under 200, it's workable with a paid RPC plan. Above that, you're fighting rate limits and rebuilding infrastructure that Yellowstone gRPC already solves.
04500 to 10,000 Wallets: Yellowstone gRPC Account Filter#
One persistent gRPC stream via the Geyser plugin handles your entire wallet list. No polling, no connection count ceiling, sub-100ms event delivery.
The filter pattern:
account_include is a list of pubkeys. Yellowstone delivers any confirmed, non-vote transaction that references any of those addresses in accountKeys. SOL transfers and token program instructions both arrive on the same stream.
For SPL: every delivered transaction carries postTokenBalances. Walk it, match owner against your wallet set:
Track the last slot you processed. On reconnect, resume from that slot and Yellowstone replays from there. Don't assume the connection is permanent.
If you're running this against a production watchlist, NLN Yellowstone gRPC provides the endpoint with the decoded transaction format these samples expect.
05Above 10,000 Wallets: Program-Level Filtering#
At a certain point, account_include becomes impractical. The payload size grows with wallet count. Filter evaluation per transaction rises. Most providers have a ceiling somewhere in the hundreds to low thousands of accounts.
The solution isn't more subscriptions. It's a different architecture.
Subscribe to programs instead of wallets:
- System Program:
11111111111111111111111111111111(all SOL transfers) - Token Program:
TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA(most SPL transfers) - Token-2022:
TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb(newer tokens)
You receive every transfer on Solana. Filter client-side:
An in-memory set of 10,000 pubkeys is about 640KB. Lookup is O(1). It's not the bottleneck.
The trade-off is volume. The Token Program processes thousands of transactions per second during active markets. You're receiving all of them and discarding most. For systems where missed events are unacceptable and the wallet list keeps growing, this is the right ceiling to never hit. One gRPC connection per program is a reasonable starting split. If Token Program volume saturates the connection, separate system and token programs onto different streams.
06One Transfer Struct for Both SOL and SPL#
SOL and SPL arrive on the same stream. They need different parsers. They shouldn't need different consumers.
The SOL decode walks preBalances/postBalances indexed against accountKeys:
For SPL, postTokenBalances gives you the amounts without needing to decode the instruction. But if you're parsing inner instructions directly, Rust handles it cleanly:
Both paths feed into one struct:
Downstream consumers (alert handlers, database writers, dashboards) work with TransferEvent. They don't need to know which path produced it, which means you can add a new token program or a new balance field to the parser without touching any consumer code, as long as the struct's fields don't change.
07Building a Solana Wallet Tracker for Multiple Users: Fan-Out and Dedup#
One gRPC stream per server process. Not one per user. Not one per wallet.
Every wallet your platform monitors sits in one subscription. Every event routes from that single stream to the users watching each wallet. The routing table does the fan-out:
Dedup matters. The same transaction can match multiple wallets in your account_include filter if both the sender and receiver are in your list. Without dedup on signature, the same event hits every downstream consumer twice. A set keyed on signature is sufficient for most deployments. For multi-process systems, use Redis with a 30-second TTL.
Delivery options:
- WebSocket: push directly to the user's open connection. Simple, no queue.
- Message queue (Kafka, Redis Streams): write per user ID, let the consumer handle delivery. Better for reliability.
- Webhook: HTTP POST to a user-configured endpoint. Good for integrations, requires retry logic.
Slot gap detection. Track the last slot received. If the next slot jumps by more than five, assume a gap. For wallets where completeness matters, run getSignaturesForAddress for the affected wallets in the gap window and process any transactions you missed.
If you need pre-indexed wallet transfer history without running the stream yourself, NLN Wallet Transfers covers historical and live data in a queryable format.
08FAQ#
Why am I missing SPL token transfers when monitoring a wallet address?
SPL tokens live in token accounts, not wallet addresses. When you subscribe with account_include: [wallet_address], Yellowstone delivers transactions that touch the wallet address itself. A token transfer touches the token account, not the wallet. Use postTokenBalances on every incoming transaction and match on the owner field: that field holds the wallet address even though the account address is different.
What is an associated token account?
A program-derived address that holds a wallet's balance for one specific token mint. Derived from three seeds: wallet pubkey, token program ID, and mint pubkey. One ATA per wallet per mint. The token program owns these accounts, not the wallet. When someone sends you USDC, they send it to your USDC ATA, not your wallet address.
How do I subscribe to a Solana wallet with Yellowstone gRPC?
Send a SubscribeRequest with a transactions filter. Set vote=False, failed=False, and account_include to your list of wallet pubkeys. Yellowstone delivers any transaction that includes those accounts in its accountKeys. Parse postTokenBalances from each transaction to catch SPL transfers alongside SOL.
How many wallet addresses can I put in a single account_include filter?
Provider-dependent. Above a few hundred accounts the filter payload grows large and evaluation cost rises. For larger watchlists, split into multiple subscriptions or switch to program-level filtering and match client-side.
What's the difference between accountSubscribe and transactionSubscribe for wallet monitoring?
accountSubscribe via WebSocket RPC sends account state snapshots: the new account data after a change, not the transaction that caused it. No instruction details, no token balance changes. transactionSubscribe via gRPC sends the full transaction including all instructions, preBalances, postBalances, and token balance arrays. For wallet tracking, you want transactions.
How do I detect both inbound and outbound transfers for a wallet?
Both appear in the same transaction. For SOL: compare preBalances[i] and postBalances[i] at your wallet's index in accountKeys. Negative diff means sent, positive means received. For SPL: compare preTokenBalances and postTokenBalances matched on accountIndex, filtered by owner equal to your wallet address.
Can I monitor all token transfers without knowing the mint in advance?
Yes. postTokenBalances includes the mint for every token in the transaction. Filter by owner to find your wallet's changes, then read the mint from the same entry. No pre-specification needed.
What happens when I need to monitor more than 10,000 wallets?
Switch to program-level filtering. Subscribe to the System Program (11111111111111111111111111111111), Token Program (TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA), and Token-2022 (TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb). Filter client-side with an in-memory set. Volume is high but there's no filter ceiling.
How do I use postTokenBalances to track SPL transfers?
Walk postTokenBalances on every received transaction. Each entry has accountIndex, mint, uiTokenAmount (with amount and decimals), and owner. Match owner against your wallet set. Diff against preTokenBalances at the same accountIndex to get the amount change. Entries that appear only in postTokenBalances represent newly created token accounts.
How do I handle dropped gRPC connections without missing events?
Track the last slot you processed. On reconnect, pass that slot in your SubscribeRequest so Yellowstone replays from that point. For critical gaps, run getSignaturesForAddress against watched wallets for the reconnect window as a fallback.
How do I build a wallet tracker for multiple users without one stream per user?
One gRPC stream per server process. Keep a routing table mapping wallet pubkey to a list of user sessions. When a transaction arrives, extract wallet pubkeys from accountKeys and postTokenBalances owners, look them up in the routing table, push to each matched session. Dedup on transaction signature before routing.
Does this work for Token-2022 tokens?
Yes. Token-2022 uses address TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb. Add it to your account_include filter alongside the original token program address. The postTokenBalances format is identical. Your parser checks both program IDs when identifying SPL instructions.
For the raw stream, NLN Yellowstone gRPC handles the subscription infrastructure. For pre-indexed wallet transfer history without running your own parser, NLN Wallet Transfers covers historical and live data in a queryable format.
Every benchmark in this blog runs against our public endpoints.
Spin up an RPC, WebSocket, or gRPC endpoint in under a minute. Flat pricing, no request caps. Reproduce the numbers for your own workload.