DOC# VANTAD SLUG vanta_desktop_tauri_wallet PRINTED 2026-05-06 03:47 UTC

Vanta Desktop: a Tauri wallet that ships its own full node

Most desktop wallets are thin RPC clients that talk to somebody else's node. The Vanta desktop app spawns vantad and the L2 sidecar as Tauri sidecar binaries, owns their PIDs, and adopts orphans on restart. Here is how that came together.

FROM
Dax the Dev <[email protected]>
SOURCE
https://blog.skill-issue.dev/blog/vanta_desktop_tauri_wallet/
FILED
2026-04-13 21:39 UTC
REVISED
2026-04-23 19:31 UTC
TIME
13 min read
SERIES
Vanta In Practice
TAGS
#vanta #tauri #rust #desktop #wallet #sidecar

The user-facing pitch for Vanta is short: open the wallet, click send, watch a private transaction settle on a chain you can verify yourself. The version of that pitch that’s actually true requires three processes: a C++ Bitcoin Core fork (vantad), a Rust L2 sidecar (vanta-node), and a UI. Most “desktop wallets” in 2026 ship the UI and trust someone else for the other two. We didn’t want to ship a wallet like that, and the answer turned out to be Tauri.

This post is a tour of vanta/vanta-desktop — the Tauri 2.x app that bundles vantad and vanta-node as sidecars, runs them under PID supervision in a Rust host, and exposes the resulting capability to a React frontend through #[tauri::command] IPC.

Sister reads: Vanta: a Bitcoin fork with ZK at consensus is the chain itself, and The vanta sidecar architecture is the deeper dive on the L2 daemon.

Why Tauri at all

I spent an unreasonable number of hours on this question. The candidates were Electron, Tauri, native (Swift / Cocoa for Mac, GTK or Qt elsewhere), and a Wails-style Go-with-WebView setup. The constraints:

  1. The wallet has to ship a full node binary. Not link to it — ship it as an external file inside the app bundle. That binary is ~25 MB on macOS aarch64.
  2. The node has to run as a child process of the app, with the app owning its PID and capable of cleanup on quit.
  3. The UI is React because the web wallet UI already existed and I wasn’t rewriting it.
  4. The signing path uses Apple’s Developer ID program. The bundle has to be signed and notarised, including the sidecars.

Electron was out because the bundle bloat (Chromium ~300 MB) plus the historical Electron-IPC-as-XSS attack surface was a non-starter for a wallet. Native Mac was out because we ship Linux too. Wails was tempting but the sidecar story for non-Go binaries is awkward.

Tauri 2.x ticked every box: small bundle (the WebView is OS-provided), sidecars are first-class via the externalBin field, the IPC contract is generated from #[tauri::command] Rust functions, and the host process is a normal Rust program where I can do std::process::Command::new(...) exactly the way I’d do in any CLI.

The tauri.conf.json declares the sidecars verbatim:

"externalBin": [
  "binaries/vantad",
  "binaries/vanta-node",
  "binaries/vanta-cli"
]

Tauri’s bundler will copy binaries/vantad-aarch64-apple-darwin (note the target-triple suffix it requires) into the .app’s Contents/MacOS/ directory and code-sign it with the same identity as the host binary. From the Rust side I get a path I can spawn against. Done.

The sidecar inventory

There are three sidecar binaries and they have different jobs.

vantad is the C++ Bitcoin Core fork. It’s the consensus node — it runs the SHA-256 PoW mainnet, validates blocks, holds the L1 UTXO set, exposes JSON-RPC on a port. From the desktop app’s point of view it is the ground truth for “what does the chain say.”

vanta-node is the Rust L2 sidecar. It indexes commitments and nullifiers from L1 OP_RETURN anchors, maintains the SMT, and exposes a REST API. The shielded balance, the SMT root, the nullifier set — that’s all here. The desktop app talks to it on a separate port.

vanta-cli is the C++ command-line client. It’s there for power users and debugging. The wallet doesn’t shell out to it for anything load-bearing, but it’s bundled because if you have vantad you almost always want vanta-cli too.

The sidecar build script is short and readable — setup-sidecars.sh just searches well-known paths, copies the binaries into src-tauri/binaries/, and renames them with the target-triple suffix Tauri’s bundler expects. The release pipeline (build-release.sh) builds vantad and vanta-node from source first, then runs pnpm tauri build, then notarises the resulting .dmg separately because Tauri 2.x notarises the .app but not the DMG wrapper.

The PID supervisor

Once you’ve decided to ship a binary, you’ve inherited a job: babysit its process. The supervisor lives in src-tauri/src/node.rs and the centerpiece is the NodeManager struct.

pub struct NodeManager {
    l1_process: Option<Child>,
    l2_process: Option<Child>,
    l1_adopted: bool,
    l2_adopted: bool,
    l1_bin: Option<PathBuf>,
    l2_bin: Option<PathBuf>,
    pub l1_logs: LogBuffer,
    pub l2_logs: LogBuffer,
    app_handle: Option<tauri::AppHandle>,
}

A few things in here that took longer than they should have to get right.

Adoption. The desktop app uses dedicated ports — 19332 for L1 RPC, 19333 for P2P, 19380 for the L2 API — so it never collides with a standalone vantad running elsewhere on the same machine. But it does collide with itself if a previous run died ungracefully. So start_l1 first probes the port: if something is listening and it answers getblockchaininfo correctly, we adopt it (return PID 0 as a sentinel). If something is listening but not responsive, we kill the orphan with lsof -ti :PORT | xargs kill and respawn. If the port is free, we spawn fresh.

This logic is not optional. The first version of this code didn’t have it and every quit-and-relaunch produced a “port in use, vantad failed to start” error that confused absolutely everybody.

Pipe draining. A C++ process logging to stdout will eventually fill the OS’s 64 KB pipe buffer if nobody reads it, then block on the next write(). vantad with -printtoconsole is a heavy logger. The host has to drain the pipes constantly. The function that does it is small enough to quote whole:

fn drain_pipe<R: std::io::Read + Send + 'static>(
    pipe: R,
    label: &'static str,
    log_buf: LogBuffer,
    event_emitter: Option<tauri::AppHandle>,
) {
    std::thread::spawn(move || {
        let reader = std::io::BufReader::new(pipe);
        for line in reader.lines() {
            match line {
                Ok(text) => {
                    tracing::debug!("[{label}] {text}");
                    log_buf.push(text.clone());
                    if let Some(ref app) = event_emitter {
                        let _ = app.emit("node-log", serde_json::json!({
                            "source": label,
                            "line": text,
                        }));
                    }
                }
                Err(e) => {
                    tracing::debug!("[{label}] pipe read error: {e}");
                    break;
                }
            }
        }
    });
}

Each line goes three places: the Rust tracing log, a 200-line ring buffer that the frontend can pull on demand (status returns the last 20), and a Tauri event so the frontend can render a live console. This last one is the thing that turned a black-box “is the node alive” indicator into a full-screen log view that’s actually useful to debug failures.

Auto-config. Before vantad starts, the host writes a fresh vanta.conf into the desktop-isolated data dir at {home}/.vanta-desktop/l1/vanta.conf. The config is hardcoded for the desktop’s port plan, points at the seed nodes, sets txindex=1 so the L2 watcher can find historic OP_RETURN anchors, and disables Bitcoin-style DNS seeding (we’re not on Bitcoin’s network). The user never sees this file unless they go looking.

Sequenced startup

The first wallet release would just spawn both nodes and hope. The result was a race condition: vanta-node would come up before vantad’s RPC was reachable, fail its first poll, and die. We added sequenced_startup so the L2 only starts after the L1’s RPC has actually answered.

Stage 1: Start L1 (may adopt)
Stage 2: Wait for L1 RPC up to 60s, exponential backoff
Stage 3: Create / load default wallet via RPC
Stage 4: Start L2 (now that L1 is confirmed reachable)
Stage 5: emit "ready" event to frontend

Each stage emits a Tauri event the frontend subscribes to. The first-launch UX is a five-stage progress meter that goes “spawning vantad… RPC ready… wallet loaded… spawning vanta-node… ready.” On a warm cache that whole flow takes about 4 seconds. On a cold first launch it’s closer to 12. Better than 12 silent seconds with a spinner.

If anything fails, the failure stage gets the last 15 lines of stdout/stderr appended into the error message. The user sees not “vantad failed” but “vantad exited during startup. Last output: …”. That diagnostic surface alone has paid for itself ten times over in support tickets I didn’t have to chase.

The IPC contract

The frontend never speaks JSON-RPC to vantad directly. Every UI action goes through a #[tauri::command] defined in commands.rs. The lib.rs registration is a single invoke_handler macro:

.invoke_handler(tauri::generate_handler![
    commands::wallet_init,
    commands::wallet_info,
    commands::wallet_balance,
    commands::wallet_notes,
    commands::wallet_send,
    commands::wallet_sync,
    commands::wallet_pubkey,
    commands::rpc_call,
    commands::start_nodes,
    commands::node_start_l1,
    commands::node_start_l2,
    commands::node_stop_l1,
    commands::node_stop_l2,
    commands::node_status,
    commands::l2_status,
    commands::swap_initiate,
    commands::swap_participate,
    commands::swap_list,
    commands::swap_inspect,
    commands::get_settings,
    commands::set_settings,
])

Every command is a typed Rust function that Tauri generates a TypeScript stub for. The frontend imports invoke('wallet_balance') and gets back a typed JSON response. There’s no HTTP server inside the app, no localhost:8085, no possibility of a malicious website hitting the wallet’s API.

This is a privacy property as well as a security one. A web wallet that runs on localhost:8085 is reachable by any browser tab. A Tauri wallet that uses the IPC bridge isn’t. The wallet’s csp is null in tauri.conf.json only because the frontend doesn’t load anything cross-origin — every “fetch” is actually an invoke.

Linux/NVIDIA, the cursed stanza

Two lines in lib.rs earned a comment longer than they are:

#[cfg(target_os = "linux")]
{
    if std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").is_err() {
        std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
    }
}

webkit2gtk on NVIDIA’s proprietary driver under Wayland tries to use a DMA-BUF renderer path that crashes with “Error 71 (Protocol error) dispatching to Wayland display.” Disabling it forces software compositing, which is fine. This one bug ate a weekend before the workaround landed.

The wider lesson: when you ship a desktop app you become a desktop developer, and “desktop developer” means “the OS will surprise you in ways the web never has.” Budget for it.

macOS sign + notarise

Apple’s developer pipeline for distributing an app outside the Mac App Store is its own genre of misery, but it’s a solved misery. The build-release.sh script automates the whole thing:

  1. Build vantad (C++) and vanta-node (Rust release) from source.
  2. Copy them into src-tauri/binaries/ with target-triple suffixes.
  3. Load APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID from .env.local.
  4. Run pnpm tauri build. Tauri auto-signs the .app with the Developer ID identity declared in tauri.conf.json (Developer ID Application: Hayden Porter-Aylor (9HD4Q82U58)).
  5. Submit the .dmg separately to xcrun notarytool.
  6. Staple the resulting ticket with xcrun stapler staple.
  7. Verify everything with codesign --verify --deep --strict --verbose=2 and spctl -a -t open -v.

The reason step 5 exists at all is that Tauri 2.x notarises the .app but not the .dmg that wraps it. Gatekeeper checks the outer file when a user downloads the DMG, so we have to submit the wrapper separately. This is documented in approximately zero places. I figured it out by attempting to install my own DMG on a clean VM and watching Gatekeeper refuse it. Two hours of head-scratching later, the staple step landed.

The end state: a user downloads Vanta Wallet.dmg, double-clicks it, drags the app to Applications, and Gatekeeper signs off without a “this came from the internet” prompt. That’s the outcome that matters — and it would not be possible without the Tauri sidecar pattern signing the inner binaries with the same identity.

What I changed my mind about

I started the desktop project genuinely planning to ship the existing wallet-ui as a webpage and tell people to run a vantad themselves. The friction of that — every user a node operator on day one — was always going to be a non-starter for everybody but engineers like me. The desktop app is the answer to “I want my mom to be able to use this,” and Tauri’s sidecar feature is what made the answer cheap enough to ship.

If you’re building a wallet for a privacy chain in 2026 and you skip the embedded full-node story, you are shipping an indexed light client and calling it a wallet. That’s fine for some products. It is not fine for this one. The whole pitch of Vanta is you don’t have to trust an indexer. If the wallet trusts an indexer the pitch evaporates.

TODO: Dax confirm

Further reading

← Back to article