DOC# VANTAE SLUG vanta_explorer_rust_indexer PRINTED 2026-05-06 03:47 UTC

Block explorers for privacy chains: a Rust indexer for vanta

Patching btc-rpc-explorer got us to 'works.' Then we wrote vanta-explorer in Rust + React: an Axum backend, SQLite indexer, and a SPA that renders shielded transfers as opaque commitments without lying about what it knows.

FROM
Dax the Dev <[email protected]>
SOURCE
https://blog.skill-issue.dev/blog/vanta_explorer_rust_indexer/
FILED
2026-04-13 17:34 UTC
REVISED
2026-04-17 05:52 UTC
TIME
8 min read
SERIES
Vanta In Practice
TAGS
#vanta #rust #explorer #axum #react #privacy

When you’re forking Bitcoin Core, you can’t get away with not having a block explorer. People will ask you for one within hours of finding out the chain exists. So Vanta has had two: the patched btc-rpc-explorer (Node.js, the original “works in a weekend” answer) and the from-scratch vanta-explorer (Rust + React, the “actually models a privacy chain correctly” answer). This post is about how we got from one to the other.

The interesting question isn’t “how do you write an explorer” — that’s well-trodden — it’s “how do you write a privacy explorer that displays opaque commitments without misrepresenting what it knows.”

Phase one: patch btc-rpc-explorer

The first explorer was a 5-day patch on top of janoside/btc-rpc-explorer. The diff is in explorer/ and the work was mostly: rename strings (bitcoinvanta everywhere), swap currency labels (BTC → VANTA, sat → zat), point at vantad instead of bitcoind, fix the mining-template URL, and update the favicon.

The 2026-04-13 commit log shows the rebrand pass:

de8efe0b explorer: rebrand patches zeracoin -> vanta

This explorer is Node.js, ships a multi-megabyte node_modules, and rendered transactions as transparent UTXOs because that’s what its templates are built for. Witness v2 commitments showed up in the UI as value: 0.0 outputs of type witness_unknown, which is technically accurate but extremely useless. A user looking at our chain through this explorer saw transactions and concluded “all the value is in coinbase outputs.” Wrong, but the explorer wasn’t lying — it was just showing what its model of “transaction” knew how to show. The real value lived in commitments outside its model.

Phase two: write a Rust explorer

I started vanta-explorer/ on 2026-04-13 (2db4e060 explorer: scaffold vanta-explorer (Rust backend + React web)). The pitch was “an explorer that knows what a witness v2 commitment is, doesn’t pretend transparent volume is the only volume, and gives me a tool I can extend without fighting a Node.js codebase that wasn’t designed for it.”

The shape:

The full backend Cargo.toml:

[dependencies]
axum = { version = "0.7", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.6", features = ["cors", "trace", "compression-br", "fs"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "macros", "migrate", "chrono"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
serde = { version = "1", features = ["derive"] }
chrono = { version = "0.4", features = ["serde"] }
async-stream = "0.3"

Reqwest with rustls-tls to skip the OpenSSL dependency. SQLite with chrono support so timestamps are DateTime<Utc> end-to-end. async-stream for SSE — we ship server-sent events for live tip updates so the explorer’s homepage updates within a second of a new block.

The startup is the canonical four-task pattern:

indexer::spawn_all(state.clone());

let app = api::router(state);

let listener = TcpListener::bind(&bind_addr).await?;

tokio::select! {
    res = axum::serve(listener, app) => { res?; }
    _ = shutdown_signal() => {
        info!("shutdown requested, exiting");
        std::process::exit(0);
    }
}

The std::process::exit(0) on shutdown is a deliberate cheat. Background pollers and SSE streams are infinite loops; the tokio runtime drop blocks waiting for them to finish, which they never do. Calling exit explicitly when the user hits Ctrl-C makes the explorer shut down in milliseconds instead of however long the runtime decides to wait. Not pretty; it works.

How shielded transfers are rendered

Here’s the part I want to be precise about. From the executive-summary paper:

Every non-coinbase witness v2 output carries nValue = 0 on L1; the real amount lives inside the note commitment preimage and is never observable on the public ledger.

The explorer can read every transaction in every block, but it cannot read amounts on shielded outputs. That’s the whole point. So the question for a privacy-explorer designer is: what does the user see?

Three options were on the table.

Option A: lie. Pretend the output value is what getrawtransaction returns (zero) and label it “0 VANTA.” Technically accurate, deeply misleading.

Option B: hide. Don’t show shielded transactions at all. Filter them out of the block view. Cowardly; users can read the raw RPC and see them.

Option C: render the commitment as the artefact. Show the transaction. Show its inputs and outputs as opaque commitments — 32-byte hex strings that are what the chain knows. Show that the proof verified. Don’t pretend to know more than that.

We picked C. The 2026-04-16 commit c912fc04 explorer: ZK transfers first-class + genesis scan + proof verification is when this work landed. The proofs API endpoint pulls from vanta-node’s 500-slot proof event ring buffer (the one the zkvm engineering paper describes) and the explorer renders each verified proof with the public-input slots: SMT root, input commitments, nullifiers, output commitments, signed value_balance.

A user looking at a shielded transaction sees:

That’s the whole story the chain has for that transaction. The explorer isn’t hiding anything; it’s rendering the right artefact.

The L2 poller

indexer/l2_poller.rs is the module that talks to vanta-node instead of vantad. It polls the L2 sidecar’s REST API on a configurable interval and pulls:

The explorer never tries to decrypt notes. The encrypted-note inbox at vanta-node is for wallets, not for explorers — only the recipient’s secret key can decrypt. The explorer’s job is to render the public artefacts and link them.

Pool stats come from the pool_poller against the public-pool’s NestJS API (the 2026-04-13 commit dbe62058 explorer: map real public-pool NestJS shape for /api/pool is when that contract got nailed down). The explorer’s pool page shows aggregate hashrate, recent shares, and recent block finds — it’s a separate data source from L1 because the pool tracks shares and miners, not chain state.

The polish pass

A bunch of small commits in mid-April were polish:

Each of these is a half-day of work. The explorer is eternal polish — there’s always one more chart, one more endpoint, one more responsive-layout tweak. I’m choosing to call it done at “users can navigate from a transaction to its proof to its L2 commitment to its receiving address.”

What I would do differently

  1. Don’t start with the patched explorer at all. It got us to “we have an explorer” in three days, which mattered for the launch story. But the eventual full rewrite was inevitable. If I were doing this again I’d skip phase one and accept a one-week longer runway to launch.
  2. Push more rendering to the client. The explorer renders most pages server-side and ships HTML. A more aggressive split (server is only the API, client is all of the rendering) would simplify the backend further. The current setup is fine; it could be cleaner.
  3. Move the SQLite into a real time-series database. SQLite is lovely for the indexed transactional data, but pool metrics + historic hashrate + mempool depth want a TSDB-shaped store (downsampling, retention policies, etc.). On the list, not urgent.

What I changed my mind about

I started building this thinking the privacy aspect would be the hardest part — that getting the UI to render commitments correctly without leaking would be a design conversation. It wasn’t. The hardest part was the boring stuff: making the SQLite indexer fast enough to keep up with 1-minute blocks while also catching up from a cold start; making React Router not lose its mind when a deep-link lands on a page whose data isn’t loaded yet; making the homepage’s hashrate chart not janky.

The privacy rendering, once we’d decided on Option C, was code. The rest of the explorer is the kind of work that explorers always are.

Further reading

← Back to article