Block explorers for privacy chains: a Rust indexer for vanta
Sections
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 (bitcoin → vanta 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:
- Backend (
vanta-explorer/backend) — Rust, Axum 0.7, SQLite viasqlx, pollsvantadandvanta-nodeon intervals. Serves/api/*. - Web (
vanta-explorer/web) — React + Vite + Tailwind. Recharts for hashrate/throughput. SPA with React Router. Runs as static assets served by the Axum backend on the same port. - Indexer modules:
l1_poller,l2_poller,mempool_poller,pool_poller. Each is a tokio task that pulls its source on a fixed interval and writes to SQLite. - API modules:
blocks,tx,address,mempool,network,pool,proofs,anon,l2,search,tip,sse. Each is its own axum router section.
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 = 0on 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:
- the transaction’s L1 outputs (mostly
nValue = 0witness v2 commitments + maybe an OP_RETURN anchor) - a “ZK proof verified” badge
- the public inputs from the proof, byte-accurately
- the SMT root the proof was verified against
- the nullifier (so they can confirm the spend isn’t replayed)
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:
/statusfor SMT root + commitment count + nullifier count/proofs/recentfor the proof event ring buffer- per-commitment lookup as the explorer’s UI deep-links into specific notes
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:
6c374159 explorer: populate l1_txs + real TxDetail— moved transaction-detail rendering from a placeholder to actual chain data30fe0a04 explorer: persist pool metrics + historic hashrate chart— historic hashrate via SQLite-backed time-series600d2a03 explorer: code-split recharts via React.lazy— Recharts is large; lazy-load it so the homepage stays fast96333d42 explorer: client-side Merkle verify tool— let users paste a transaction id and a Merkle root and verify inclusion locally, without the explorer22698e6e explorer: phase 9 polish + fast backend shutdown— thestd::process::exitshutdown trick above
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
- 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.
- 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.
- 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
vanta-explorer/backend— the Rust + Axum + SQLite indexervanta-explorer/web— the React + Vite SPAexplorer/— the original patched btc-rpc-explorer- Vanta: a Bitcoin fork with ZK at consensus — the chain
- The vanta sidecar architecture — the L2 the explorer reads from
janoside/btc-rpc-explorer— the upstream we forked for phase 1