The vanta wallet HTTP API: an Axum bridge to vantad RPC
Before the Tauri desktop wallet there was an Axum web wallet. It is a five-route Rust service that wraps vantad's JSON-RPC and serves a single static page. Boring on purpose — and the boring is the point.
- FROM
- Dax the Dev <[email protected]>
- SOURCE
- https://blog.skill-issue.dev/blog/vanta_wallet_axum_api/
- FILED
- 2026-04-13 18:46 UTC
- REVISED
- 2026-04-17 05:52 UTC
- TIME
- 8 min read
- SERIES
- Vanta In Practice
- TAGS
The first wallet I shipped for Vanta wasn’t a desktop app. It was a Rust/Axum HTTP service that wraps vantad’s JSON-RPC behind a small REST API and serves a single static HTML page. Five routes, one Cargo.toml, one main.rs, ~250 lines of Rust. Boring on purpose. The boring is the point — when you’re bringing up an L1, the wallet has to be a tool you can debug inside of, not a black box.
This post is a read-along of wallet/src/main.rs, what the route surface buys you, what’s coming next as the desktop app picks up the unified-dashboard work, and what the Axum service is not (it’s not a key holder; it’s a thin bridge).
The dependency tree
The whole Cargo.toml fits in a screenshot:
[dependencies]
bitcoin = { version = "0.32", features = ["serde", "rand-std"] }
bitcoincore-rpc = "0.19"
axum = { version = "0.7", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.6", features = ["cors", "fs"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
That’s it. Axum for routing, bitcoincore-rpc for the typed RPC client (which works against vantad because the RPC contract is unchanged from Bitcoin Core v27.0), bitcoin for address parsing, tower-http for CORS and static-file serving. No database, no auth middleware, no template engine, no ORM. The wallet is a pass-through — the only real state is whatever vantad says.
This is the choice I want to be loudest about. The temptation when you’re forking Bitcoin is to ship a wallet that re-implements everything vantad already does. Don’t. The wallet’s job is to make vantad legible from a browser.
The route surface
Five HTTP endpoints, registered in main() with the canonical Axum router:
let app = Router::new()
.route("/", get(index))
.route("/api/info", get(get_info))
.route("/api/transactions", get(get_transactions))
.route("/api/blocks", get(get_recent_blocks))
.route("/api/send", post(send_zer))
.route("/api/address/new", post(new_address))
.layer(CorsLayer::permissive())
.with_state(state);
Each route maps to one or two RPCs. Walking through them:
GET / — the index. This serves the static HTML+JS page bundled into the binary at compile time via include_str!("../static/index.html"). The page calls the four JSON endpoints below. Compile-time bundling is a one-binary deploy story: copy vanta-wallet, run it, the UI is there.
GET /api/info — wallet + network status. Five RPCs in one handler:
let balance = rpc.get_balance(None, None).unwrap_or_default();
let unconfirmed = rpc.get_balances().map(|b| b.mine.untrusted_pending).unwrap_or_default();
let block_count = rpc.get_block_count().unwrap_or(0);
let info = rpc.get_network_info().ok();
let mining = rpc.get_mining_info().ok();
Returns WalletInfo { balance, unconfirmed_balance, block_count, connections, mining_address, difficulty }. This is the polled-every-5-seconds heartbeat the index page uses.
GET /api/transactions — last 50. A direct passthrough to listtransactions, with a small Rust struct mapping over the result so the JSON the browser sees is stable across bitcoincore-rpc upgrades.
GET /api/blocks — recent 10 blocks. Walks (height-10..=height), calls getblockhash and getblockinfo for each, returns a Vec<BlockInfo>. The single-RPC-per-block makes this O(n) but n is 10, so it’s fine.
POST /api/send — send VANTA. Takes { address, amount }, parses the address against the network (so Z-legacy and vnt1-bech32 both work), constructs an Amount from the float, and calls send_to_address. Errors are wrapped with BAD_REQUEST for parse failures and INTERNAL_SERVER_ERROR for RPC failures.
POST /api/address/new — fresh receiving address. Calls getnewaddress with an optional label.
That’s the entire surface. There is intentionally no wallet/create, no key-import, no PSBT signing. Those operations go through vanta-cli directly — the wallet user is implicitly a vantad user. This is fine for the testnet phase. It is not fine for shipping to the public, which is why the desktop app exists.
The settxfee dance
One detail in the main() startup that took me longer than it should have:
let _ = rpc.call::<serde_json::Value>("settxfee", &[serde_json::json!(0.0001)]);
Bitcoin Core’s fee estimator uses historical mempool data to predict the fee per byte. On a fresh chain with low traffic, it has no data. The default behaviour when the estimator can’t decide is to error on sendrawtransaction — not to fall back to a default. You discover this the first time you try to send a tx on a fresh chain and get back “fee estimation failed.”
The fix is settxfee at startup with a sane fallback. 0.0001 VANTA/kB is roughly nothing in real terms (one ten-thousandth of a unit, when each block pays out 100,000 units), but it’s enough to satisfy the estimator’s “have a fee” check. Same trick is in txbot/src/main.rs for the same reason.
The Bitcoin Core devs are aware of this footgun and there’s been talk of a fallbackfee config that fires automatically. For now, a one-line workaround at every RPC client’s startup.
Auth, or the lack thereof
The Axum wallet binds to 0.0.0.0:8085 and runs CorsLayer::permissive(). Translation: anyone on the network can hit it. There’s no token, no password, no rate limit.
This is fine for what it is — a single-operator tool you run on a host you control, with the assumption that the only consumer is the static page bundled into the same binary. It is not fine for a multi-tenant deployment. The host firewall is the auth boundary. If you put this on the open internet you’ve made a mistake.
The desktop app fixes this by running the equivalent logic in-process via Tauri IPC — there is no HTTP listener, so there’s nothing for a browser tab on a malicious site to talk to. Read The vanta sidecar architecture and Vanta Desktop for the longer story on that boundary.
What the API doesn’t have, and where it goes
The Axum wallet was written before the privacy layer was wired in. So it shows transparent UTXOs only. That’s why the wallet-ui split exists: there’s a wallet-ui/ React app that calls both the Axum service and vanta-node’s REST API, and renders a unified view that interleaves transparent transactions with shielded notes.
The 2026-04-17 commit message that motivated this whole post —
wallet-ui: merge privacy view into unified dashboard + rescan endpoint
— is what landed when we collapsed the previously-separate /privacy page into the /dashboard page so users see one balance (“private balance”) and one feed of activity. Behind the scenes the dashboard is calling:
GET /api/infoagainst the Axum wallet for L1 status (block count, connection count)GET /statusagainstvanta-nodefor the L2 status (commitment count, nullifier count, SMT root)GET /notesagainstvanta-nodefor the wallet’s shielded note inventoryPOST /api/sync(the new rescan endpoint) to trigger a re-scan of L1 + L2 against the wallet’s keys
The unified-dashboard logic lives in wallet-ui/src/pages/dashboard.tsx. The L2 status card is a five-second auto-refresh that pulls SMT root, commitment count, nullifier count, and last block height, and renders it as four monospace numbers under the “L2 Privacy Layer” header. That’s the surface a user sees; behind it are two Rust services and a C++ node.
Why a separate service instead of merging into vanta-node
A reasonable design question: why does wallet/ exist at all? Why isn’t this one of vanta-node’s API endpoints?
Two reasons.
Bitcoin-RPC stays as the wallet boundary. The set of operations the L1 wallet does (send, receive, balance) maps 1:1 to Bitcoin Core RPC calls. Wrapping those in a small Axum service means the service is replaceable by anything that speaks the same five endpoints — a CLI, a different language wallet, a hardware-wallet integration. That’d be harder if the L1 wallet primitives were tangled into the L2 sidecar’s REST API.
vanta-node runs without a wallet. A node operator who wants to index the chain but doesn’t have a wallet on the node — say, a cold-storage setup or an indexer service — should be able to run vanta-node cleanly without a transparent-wallet listener implicitly bound. Keeping them separate means each service does one job.
The desktop app is the unified frontend that talks to both. The web wallet is the developer/debug frontend that talks to the L1 service. In the medium term I expect the web wallet to be deprecated in favour of “you run the desktop app” — but the Axum service is staying for as long as anyone wants a portable HTTP-shaped wallet.
What I would do differently
Three things.
- Bind to 127.0.0.1 by default. The current
0.0.0.0:8085is a footgun for someone who runs this in a non-trusted network without thinking about firewalls. Default to localhost; the user can opt-in to LAN exposure with a flag. - Drop
bitcoincore-rpcfor hand-rolledreqwest. The crate is fine but I have hit type-mismatch issues every timevantadreturns a slightly off-vanilla shape (e.g. our extravalue_balancefield on transactions). Going hand-rolled lets the wallet evolve with the chain without the upstream crate’s maintainer in the loop. - Type the receive endpoint against bech32. Right now
getnewaddressdefaults to whatever the node is configured for (legacyZor bech32vnt1). The wallet should passbech32explicitly so the address format the user sees is consistent.
None of these are urgent. The Axum wallet does its job. It’s not the wallet I want to ship to a million users. It is the wallet I want behind the wallet I ship to a million users — a debug surface for me, when something is wrong with the chain and I want to talk to it from curl.
Further reading
wallet/src/main.rs— the entire Axum service, 250 lineswallet/Cargo.toml— the dependency tree (small on purpose)wallet-ui/src/pages/dashboard.tsx— the React dashboard that calls this service- Vanta Desktop: a Tauri wallet that ships its own full node — what replaces this for end users
- The vanta sidecar architecture — how
vanta-nodecomplements this service - Bitcoin Core JSON-RPC docs — the upstream contract the Axum service wraps