DEV Community

Jeff Ronnie
Jeff Ronnie

Posted on

What a Bitcoin Blockchain Explorer Taught Me About Reading Data I Didn't Write

I thought building a blockchain explorer was going to be a JSON parsing exercise. Call bitcoind over JSON-RPC, get a block back, walk its transactions, display them nicely. I had done plenty of "hit an API, render the response" work before. I expected this to be a variation on that, with Bitcoin as the interesting label on an otherwise familiar shape of project.

It wasn't, and the difference taught me more about backend work than most of the projects I'd have called "real" ones at the time.

The block was never the hard part

Pulling a block from a regtest node was genuinely easy. One RPC call, a clean struct, done. The block height, the hash, the timestamp, the number of confirmations, all of it mapped cleanly onto something I could display without much thought. For about a day, I believed the whole project was going to be this simple, and I remember planning out the rest of the build assuming every layer down would feel roughly the same.

Then I got to transactions, and specifically to figuring out where the money in a transaction actually came from. That's when I ran into the UTXO model properly for the first time, not as a diagram in a blog post or a slide in a course, but as something I had to represent correctly in working code, with no room to gesture past the parts I didn't fully understand yet.

A Bitcoin transaction doesn't carry a balance. It doesn't say "Alice has 2 BTC and sends 0.5 to Bob." It references previous transaction outputs as inputs, spends them completely, and creates new outputs, some of which go to the recipient and some of which go back to the sender as change. There's no account state sitting anywhere waiting to be read the way there would be in a typical banking-style schema. Every "balance" is something you calculate by tracing spent and unspent outputs, not something you fetch from a single field.

That distinction sounds obvious once you've internalized it. It was not obvious to me while I was staring at a vin array full of previous transaction IDs and output indexes, trying to figure out why my explorer was displaying a transaction as spending "nothing" when it clearly had a value attached somewhere in the chain of data I was pulling.

The bug that made the model click

The specific bug was almost embarrassing once I found it. I was displaying the amount for each input by looking at the input itself, and the input doesn't carry an amount. It only carries a reference: which previous transaction, which output index within that transaction. The actual value lives back in that earlier transaction's output, not in the input referencing it. The input is a pointer, not a payload.

So my explorer was technically not wrong, it was just answering a question I hadn't actually asked. I asked "what does this input say," and it told me, accurately. I meant to ask "how much value does this input represent," which required going and fetching the transaction being referenced, finding the specific output by index within that transaction, and reading the value from there. Two different questions that happened to look like the same field on the page.

Fixing it meant resolving every input by looking up its parent transaction before I could show anything meaningful about it. That's an extra RPC call, or a lookup against data I'd already have to cache locally, for every single input, on every single transaction, in every block. It's genuinely not expensive on a regtest node with a handful of transactions sitting in front of you. It becomes a completely different scaling problem the moment you're not on regtest anymore and you're pointed at something with real transaction volume. I hadn't thought about that gap at all until I was standing in the middle of it, and it changed how I thought about the rest of the project's architecture, not just this one display bug.

Displaying data honestly versus displaying data conveniently

This is where the project stopped being about Bitcoin specifically and started being about something I now notice in basically every backend system I touch: the data as it's given to you and the data as it needs to be understood are not always the same shape, and the gap between them doesn't announce itself.

It would have been easy to keep the shortcut. Show the input, note the previous transaction ID, leave the amount blank or hardcode a placeholder, ship it as "an explorer that shows structure." Nobody looking at a demo would necessarily catch that the values were missing or wrong on the input side, especially if the rest of the UI looked polished. But an explorer that gets a transaction's inputs wrong isn't a smaller version of a working explorer, it's a broken one wearing a working one's interface. The whole value of the tool is that someone can trust what it displays without having to independently verify it against the raw chain data themselves. Once that trust is broken even in one corner of the output, the rest of the tool inherits the doubt.

That's the part that stuck with me longer than the Go syntax around channels or structs did. Writing code that compiles and returns a response is the easy bar, and it's the bar most tutorials quietly optimize you toward, because a working demo is satisfying and a subtly wrong one often looks identical from the outside. Writing code that represents the underlying system correctly, especially a system you didn't design and can't change the rules of, is a different and much less forgiving bar. Bitcoin's ledger doesn't care whether my mental model is convenient. It only cares whether my code respects how it actually works, and it will happily let you build something that runs cleanly while being quietly wrong for weeks.

The part that surprised me most

What actually surprised me wasn't the UTXO model itself, once I'd read about it enough times it stopped being new information. What surprised me was how confidently I had already been representing it incorrectly before I hit the bug. I hadn't skipped learning the concept. I understood, in the abstract, that Bitcoin uses UTXOs and not account balances. I could have explained it correctly if someone asked me directly.

The gap was between understanding a concept and having actually forced my code to obey it in every place that mattered. It's possible to know something is true and still write code that behaves as if it weren't, simply because the convenient path through the API didn't force the correct model on you. Nothing in the vin struct waved a flag saying "this field will mislead you if you treat it as a value." I had to go looking for the mismatch by noticing the output was wrong, not by having the tooling warn me in advance.

That's a specific kind of debugging I hadn't done much of before this project: not chasing a crash or an error message, but chasing a number that was technically present and simply meant less than I assumed it did.

Where this left me

I came out of that project with a habit I didn't have going in: when I'm building anything that reads someone else's data model, I now spend real time upfront figuring out what the data actually represents before I write a single line that displays it. Not what the field is called, not what type it is, but what real-world thing it's standing in for and where the ground truth of it actually lives, even if that means an extra lookup I'd rather not add.

It's a slower way to start a project. It also means I stop discovering, three days in, that I've been quietly lying to whoever is reading my output, and that trade has been worth it every time since.

What's the closest you've come to shipping something that technically ran but quietly misrepresented the system underneath it?

Top comments (0)