Skip to content
owen/richards
All writing
8 min read

A wallet's history screen is a ledger projection of raw chain data. Why balance deltas beat instruction parsing as the source of truth, where classification gets hard, and how I'd test it with an eval set instead of vibes.

Learning in public. I haven’t shipped anything on-chain yet. These posts map what I know from production payments and ledgers onto how blockchains handle the same problems.

I haven't shipped a wallet. I'm teaching myself Solana, and this post is about the part of a wallet I can reason about from my own background: the activity screen. It shows a list of lines like "Swapped 2 SOL for 312 USDC" or "Received 50 USDC from 7xK…", and every one of those lines is a derived view of a transaction that, on chain, looks like a pile of program IDs and byte arrays.

That's a ledger problem. I've spent years building systems where the raw events are messy and the thing a person reads has to be right. Here's how I'd approach it.

The chain gives you facts and instructions. Trust the facts.

A confirmed Solana transaction has two layers worth caring about. The instructions say what the sender asked for: which program, which accounts, some encoded data. The metadata says what actually happened: the lamport balance of every account before and after (preBalances and postBalances), the same for SPL tokens (preTokenBalances and postTokenBalances), the fee, any error, and the inner instructions that programs invoked on the way.

The tempting design is to parse instructions: see a Token Program transfer, print "Sent". It works for a plain transfer and then falls apart. A swap through an aggregator is one top-level instruction that fans out into inner instructions across several programs and pools. The top-level instruction tells you almost nothing about the result.

The sturdier design starts from the metadata. For the wallet's own address, compute the net change per asset: SOL (minus the fee, which is reported separately) plus the delta for each token account the wallet owns. Those deltas are the closest thing the chain offers to double-entry postings. They sum to what really moved, whatever route it took, and a ledger builder trusts postings over narrative. Instructions then become evidence for the label, not the source of the amounts.

What was asked: instructions

  • 1 · Aggregator program: route
  • Token Program: transfer (you → pool A)
  • Pool A program: swap
  • Token Program: transfer (pool A → pool B)
  • Pool B program: swap
  • Token Program: transfer (pool B → you)

Route-dependent · one top-level instruction, many inner

What happened: your net deltas

SOL−2.000000
SOL (fee)−0.000005
USDC+312.480000

Same result whatever the route · these become the postings

Feed line

Swapped 2 SOL for 312.48 USDC

Illustrative swap. The amounts come from the right column; the instructions only help pick the label.

Gotchas that make the deltas less obvious than they sound.

  • Token balances are keyed by token account, not by wallet. The wallet owns an associated token account per mint, and the metadata reports balances with an owner field. You group by owner and mint, and a mint with no pre-entry means the account was created in this transaction.
  • Wrapped SOL muddies native SOL. A swap may wrap SOL into a token account and unwrap it after, so naive per-account deltas show phantom movements. Netting wSOL against SOL for the same owner fixes most of it.
  • Failed transactions still cost a fee. A transaction with an err set changed no state beyond the fee, but the user paid it. It belongs in the feed as "Failed", with the fee as its only posting, not hidden.
  • Versioned transactions hide accounts in lookup tables. With address lookup tables, a transaction's account list isn't fully in the message, so a parser that only reads the message misses accounts. The metadata carries the extra accounts as loadedAddresses, but only if you ask for versioned transactions: getTransaction needs maxSupportedTransactionVersion set, or it errors on a v0 transaction instead of returning it. That one is easy to miss in a happy-path test.
  • Rent is a real movement. Opening a token account takes a small SOL deposit, and closing it returns it. The delta is correct, but showing "Sent 0.00204 SOL" for something the user thinks of as "received a token" needs a label that explains it.

Classification is the lossy step, so keep it separate.

Once you have the deltas, labeling is a small classifier: one asset out and one in is a swap, only inflows is a receive, only outflows to one counterparty is a send, a new token account is an airdrop or mint, and so on. I'd keep this layer deliberately separate from the amounts, for the same reason a ledger keeps postings apart from descriptions: the postings are the truth, the label is a presentation choice, and you want to be able to change your mind about the label without touching money.

That has a practical payoff. When the classifier meets something it doesn't understand, the failure is a generic line, "Interacted with a program: −0.5 SOL, +1 NFT", which is true and merely unhelpful. The failure is never a confident wrong sentence like "Received 1 NFT" for a transaction that cost the user 0.5 SOL. Fall back to the facts, and make the unknown look like an unknown. That's the same rule I use on /ask, where a weak retrieval produces "I don't know" rather than a guess.

Indexing is a reconciliation job.

A feed built by watching transactions as they arrive will miss some: a dropped websocket, a restart, a gap. And a transaction seen at processed can still be dropped, as I covered in finality for people who build ledgers. So the design that survives is the one I'd use for any ledger: an event path for speed, and a reconciler that periodically pages through the address's signature history and repairs the difference. Keying rows on the transaction signature makes the repair idempotent, the same replay-safety idea I rely on in payment pipelines. Reprocessing the same transaction twice is a no-op, not a duplicate line.

Amounts come from chain metadata, labels from a separate classifier, and a reconciler repairs whatever the live path missed.

Chain
Transaction
meta: pre/post balances · fee · err
Two paths in
Event stream
fast · can drop or reorder
Reconciler
pages signature history · repairs gaps
Postings
Per-owner deltas
keyed by signature · idempotent
Presentation
Classifier
swap · send · receive · …
Fallback line
facts only, when unsure

Postings are truth · Labels can change without touching amounts. An unknown shows as an unknown, never as a confident wrong sentence.

Architecture · A wallet activity feed is a ledger projection of chain data

How I'd test it: an eval set, not a few screenshots.

The part of this I've done before is evaluation. In evaluating LLM agents without going broke I wrote about building a small labeled set that catches regressions cheaply. The same discipline fits a classifier:

  1. Collect real transactions covering the awkward shapes: plain transfer, swap through an aggregator, wrapped SOL, account creation and closing, failed transaction, versioned transaction with lookup tables, an airdrop, an unlimited approval.
  2. Label each with the expected feed line and the exact deltas. Deltas are deterministic, so those assertions are exact. Labels are judgment calls, so those are the part to review by eye.
  3. Run it in CI. A parser change that shifts a delta fails loudly. A change that moves five labels to "Interacted with a program" is a visible regression in the diff, not something a user reports in a support ticket.
  4. Grow it from production. Every time a user reports a confusing line, the transaction goes into the set.

If a model were ever used to write friendlier descriptions, it would sit on top of these facts, and the same set would grade it: does the sentence match the deltas. The numbers would never come from the model.

What I'd want to learn next.

I've reasoned about this from documentation, not from running an indexer against mainnet, and I expect real traffic to be stranger than my list of gotchas. The next step is concrete: write a small devnet script that fetches a handful of transactions, computes per-owner deltas, and prints a feed line, then see which of the cases above actually break it. I'll write that up honestly, including what I got wrong.

Owen

Thanks for reading. Cheers.

If you spot something wrong in this post, or you’d have done it differently, tell me. I’d rather be corrected than confidently wrong. owenlrichards2000@gmail.com

— Owen