Skip to content

feat: optional Bitcoin node + BTC wallet (bitcoind + BDK sidecar) - #4

Open
erubboli wants to merge 5 commits into
mainfrom
feat/bitcoin-node-wallet
Open

feat: optional Bitcoin node + BTC wallet (bitcoind + BDK sidecar)#4
erubboli wants to merge 5 commits into
mainfrom
feat/bitcoin-node-wallet

Conversation

@erubboli

@erubboli erubboli commented Sep 1, 2026

Copy link
Copy Markdown
Member

Summary

Adds an opt-in Bitcoin stack (--profile bitcoin, same pattern as the indexer) and a BTC wallet page to the web GUI. Per the agreed plan: enable/disable stays on the host (no Docker socket in web-gui); keys are held by a light-wallet sidecar, bitcoind only provides chain data and broadcasts; network follows Mintlayer NETWORK with a BITCOIN_NETWORK override.

Architecture

web-gui (Astro) ──HTTP+basic-auth──> bdk-wallet sidecar (BIP84 keys, SQLite) ──RPC──> bitcoind

What's included

Infrastructure (profile: bitcoin)

  • bitcoind — pinned to Core 25.x (the BDK 0.29 rpc backend cannot parse the warnings array format that Core 26+ returns; hit and verified during integration testing). txindex=1 default (needed for wallet history), no host ports, no host RPC exposure.
  • bdk-wallet (bdk-wallet/, Rust/axum) — BIP84 wallet holding the keys; endpoints: create/restore wallet (mnemonic returned once), status, sync, balance, address (new/current), txs, send, fee estimates. Basic auth, internal-network only, seed persisted 0600, all amounts as satoshi strings (no floats), sync horizon = wallet creation time (avoids genesis scans).
  • init.sh optional step (passwords, network, disk/hot-wallet warnings), .env.example, make bitcoin, make bitcoin-cli, README section.

Web GUI

  • /bitcoin page — create/restore wallet with one-time seed backup step, balance cards (confirmed/pending/immature, BigInt math), QR receive, send form, transaction history, node status strip with enable instructions when offline.
  • /api/bitcoin/* — session-gated (middleware) proxy routes with server-side amount validation; sidecar outages degrade per-call instead of failing the whole page.
  • Settings → Bitcoin — show/hide sidebar pref + live node status panel; nav item gated on BITCOIN_ENABLED + pref.

Security notes

  • No Docker socket mounted anywhere.
  • Sidecar unreachable from outside the compose network; all web routes session-gated.
  • Raw/dangerous RPCs are not proxied (no dumpwallet/importwallet/stop).
  • Hot-wallet warnings in init.sh, README, and the page itself.

Test plan

  • 523/523 vitest tests (8 lib client tests + 13 API route tests added)
  • tsc --noEmit clean, astro build clean, cargo clippy clean, docker compose config valid
  • Regtest end-to-end: wallet create + seed display, 101-block fund, sync (50 BTC confirmed + 5000 BTC immature detected correctly), send with change + fee (141 sats, confirmed at height 102), history listing
  • Middleware auth verified live: /bitcoin and /api/bitcoin/* redirect unauthenticated requests

Known limitations (documented)

  • bitcoind pinned to 25.x for BDK compatibility (README documents the override).
  • Mainnet sync is heavy (~700 GB with txindex); testnet/regtest recommended for trials.
  • Pruning incompatible with wallet history sync (documented, left off by default).
  • fee-estimate returns empty on regtest (insufficient data) — expected.

Infra (profile: bitcoin, indexer-style enable):
- bitcoind service pinned to Core 25 (bdk 0.29 rpc backend cannot parse
  the Core 26+ `warnings` array format), txindex on, no host ports
- bdk-wallet sidecar: BIP84 light wallet holding the BTC keys; axum HTTP
  API (status/balance/address/txs/send/fee-estimate) with basic auth;
  seed persisted 0600, amounts as satoshi strings throughout
- init.sh optional step, .env.example vars, .gitignore entries

Web GUI client:
- src/lib/bitcoin-wallet.ts: server-side typed client for the sidecar
  with timeout + basic auth; BITCOIN_ENABLED feature flag
- 8 unit tests; verified end-to-end on regtest (create/restore, 101-block
  fund, sync, send with change + fee, tx history)
- /bitcoin page: create/restore wallet with one-time seed backup, balance
  cards (confirmed/pending/immature, BigInt math), QR receive, send form,
  transaction history, node status strip with enable instructions
- /api/bitcoin/{overview,wallet,send,sync,fee-estimate}: session-gated
  proxy routes with server-side amount validation; partial sidecar
  outages degrade independently
- Settings → Bitcoin section: show/hide sidebar page pref + live node
  status panel; nav item gated on BITCOIN_ENABLED + pref
- 13 API route tests; make bitcoin / make bitcoin-cli; README section

Verified: 523/523 tests, tsc clean, astro build ok, clippy clean,
compose config valid, regtest end-to-end (create, fund, sync, send).
- bdk-wallet Dockerfile: install libsqlite3-dev in the builder stage;
  bdk's rusqlite links the system library, so the build failed on
  machines without it (worked locally only because the dev host had it)
- bdk-wallet: vendor jsonrpc 0.13.0 with the per-request timeout raised
  15s -> 120s. bdk 0.29 hardcodes a 100-per-keychain address cache and
  imports all ~200 watch-only descriptors in one `importdescriptors`
  call; on modest hardware that exceeds 15s, the sync aborts before
  persisting its checkpoint, and every retry repeats the slow import
- BitcoinWallet UI: satsToBtc appended a stray trailing zero for
  integer BTC amounts (50 BTC rendered as "500 BTC")
- Makefile bitcoin-cli: pass -rpcport=8332 (compose binds the node RPC
  to 8332 for all networks; regtest's default 18443 is not in use)

All found by installing the stack in a fresh Ubuntu/QEMU VM (regtest):
create -> fund -> sync -> send, verified against bitcoind ground truth.
@erubboli

erubboli commented Sep 1, 2026

Copy link
Copy Markdown
Member Author

Live VM verification (fresh Ubuntu 24.04 / QEMU)

Installed the full stack from scratch inside a throwaway Ubuntu VM (regtest, 8 GB RAM, 4 vCPU) — not the fast dev host — and drove it end-to-end through the browser. This surfaced 3 real bugs, all fixed in 360b6aa:

Bug Root cause Fix
docker build of the sidecar fails bdk's rusqlite links system sqlite; builder lacked libsqlite3-dev (dev host had it, clean machines don't) add to builder apt deps
Wallet sync could never complete on modest hardware jsonrpc 0.13 hardcodes a 15 s request timeout; bdk 0.29 imports ~200 watch-only descriptors in one importdescriptors call (~35 s in the VM), aborts before persisting its sync checkpoint, and retries the slow import forever vendor jsonrpc with timeout raised to 120 s ([patch.crates-io], diff = 3 lines)
UI showed 50 BTC as "500 BTC" satsToBtc appended a stray trailing 0 for integer amounts — never caught before because only API JSON had been checked, never the rendered UI template fix

Also: make bitcoin-cli now passes -rpcport=8332 (the compose node binds 8332 for all networks; regtest's 18443 default is not in use).

Verified flow (regtest)

init.sh (bitcoin=yes) → login (password+TOTP) → create wallet → 12-word seed backup screen → receive address + QR → generatetoaddress 101 → sync → 50 BTC confirmed / 3675 BTC immature, matching bitcoind exactly → send 1.5 BTC via UI → mined → 148.49999859 BTC confirmed (exact fee deduction) → sink wallet received 1.50000000 → history lists all txs.

- init.sh: create bitcoin-data/ and bitcoin-wallet-data/ right after the
  .env write instead of inside the "start services" block. Previously a
  user declining auto-start got no dirs, and Docker then created the
  bind mounts as root, breaking the sidecar's seed persistence
  (found live in the VM; worked around with chown 1000:1000 there).
- compose: run bdk-wallet as ML_USER_ID/ML_GROUP_ID like web-gui, so
  hosts with a UID other than 1000 also match the bind-mount owner.
…me 151

- Setup wizard ("create new" and "import from seed") now initializes the
  optional BTC wallet from the same mnemonic as the ML wallet, so a single
  seed controls both chains. Non-fatal when the bitcoin profile is off or
  the sidecar is unreachable; the BTC page keeps its own fallback flow.
- middleware: Referrer-Policy no-referrer -> strict-origin-when-cross-origin.
  Chrome 151 elides the Origin header (sends "Origin: null") on form POSTs
  when the referrer policy strips referrers; Astro's same-origin CSRF check
  then rejects every form with 403, making login/setup unusable. Verified
  by header capture: minimal page -> real Origin sent; app page (no-referrer)
  -> "Origin: null". The browser default policy keeps cross-origin requests
  origin-only, so no referrer leakage is introduced.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant