Skip to content

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

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

feat: optional Bitcoin node + BTC wallet (bitcoind + BDK sidecar)#4
erubboli wants to merge 13 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, plus a unified one-seed setup for both wallets. Enable pattern: host-side only (no Docker socket in web-gui); keys are held by a BDK light-wallet sidecar; bitcoind provides chain data + broadcast; ML NETWORK drives the default Bitcoin network (BITCOIN_NETWORK overrides).

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 (bdk 0.29's rpc backend cannot parse the warnings array format from Core 26+ — found live), txindex=1, no host ports
  • bdk-wallet sidecar (bdk-wallet/, Rust/axum): BIP84 keys, create/restore (mnemonic returned once), status/sync/balance/address/txs/send/fee endpoints, basic auth, internal-network only, seed persisted 0600, satoshi-string amounts everywhere, sync horizon = wallet birthday
  • btc-explorer sidecar: self-hosted btc-rpc-explorer (built from upstream source — no official image), loopback-only, gives regtest a link target
  • init.sh optional step with disk/hot-wallet warnings, data dirs pre-created by the host user, ML_USER_ID-aware sidecar user, .env.example, make bitcoin / make bitcoin-cli, README section

Web GUI

  • /bitcoin page: create/restore with one-time seed backup, balance cards, QR receive, send form, transaction history, explorer links (mempool.space on public networks, self-hosted on regtest), node status strip with enable instructions
  • /api/bitcoin/*: session-gated proxy routes, server-side amount validation, per-call degradation on sidecar outages
  • Settings → Bitcoin: show/hide sidebar pref + live node status
  • Setup wizard: one seed now initializes both wallets (create or import); non-fatal when the bitcoin profile is off

Address typo recovery (bech32 error correction)

  • src/lib/bech32-correct.ts: BIP-173 BCH syndrome decoding locates up to 2 character errors; supports bech32 + bech32m (Mintlayer uses bech32m — verified against a live tmt1… address); BTC segwit payload rules enforced on candidates
  • Both send flows offer an explicit "Did you mean …? [Use suggested address]" — never auto-replaces (2 typos can alias to another valid address)
  • Fixes a real usability blocker: Chrome 151 sends Origin: null on form POSTs under Referrer-Policy: no-referrer, 403-ing every form → policy changed to strict-origin-when-cross-origin

Security notes

  • No Docker socket mounted; sidecar unreachable off the compose network; all web routes session-gated; no raw/dangerous RPCs proxied
  • Hot-wallet warnings in init.sh, README, and the UI; seed shown exactly once

Test plan

  • 542/542 vitest tests (15 bech32 correction, 8 client, 13 API route tests added); tsc, astro build, cargo clippy, compose config clean
  • Regtest end-to-end twice on the dev host: create/restore, 101-block fund, sync, send with change + fee, history
  • Full install inside a fresh Ubuntu/QEMU VM — found and fixed: missing libsqlite3-dev in the sidecar build, jsonrpc 15 s timeout killing sync on slower hardware (vendored with 120 s), satsToBtc rendering 50 BTC as "500 BTC", setup dir-creation gap, make bitcoin-cli port
  • Browser-verified: form login (post-CSRF-fix), typo suggestion → "Use suggested address" → send completes, explorer links, settings persistence
  • Ungraceful-shutdown recovery: chain rollback re-mined, wallet resynced, explorer tx pages 200

Known limitations (documented)

  • bitcoind pinned to 25.x for BDK 0.29 compatibility (override via BITCOIND_IMAGE)
  • Mainnet sync ~700 GB with txindex; testnet/regtest recommended for trials
  • First wallet sync must complete one large importdescriptors under the patched 120 s timeout — very slow hardware should use testnet first

@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.

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.
- 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.
The immature bucket only ever holds unmatured coinbase rewards - a
mining concept wallet users never encounter (even pool payouts arrive
as normal transactions). The sidecar API still reports it; the UI now
shows Confirmed and Pending only.
- txids in the history link to the explorer transaction page; the
  receive address links to its explorer address page
- per-network base: mempool.space (mainnet/testnet/signet); hidden on
  regtest, which has no public explorer
- compose: btc-explorer service (btc-rpc-explorer v3.5.1, built from
  upstream source - no official image is published) wired to the local
  bitcoind, published on 127.0.0.1:3002, bitcoin profile
- getBitcoinExplorerUrl(): BITCOIN_EXPLORER_URL override, else
  mempool.space for mainnet/testnet/signet, else the self-hosted
  explorer for regtest; passed to the Bitcoin page as a prop
- .env.example documents the override; 5 unit tests

Verified in the VM: 26 explorer links render on regtest, a tx page
round-trips through the explorer (HTTP 200 with tx data). Also
uncovered an unclean-shutdown chain rollback (qcow2 writeback loss);
re-mined the regtest chain and re-verified.
BIP-173's checksum is a 30-bit BCH code able to LOCATE up to two symbol
errors. New src/lib/bech32-correct.ts ports the reference decoder with
syndrome-based error localization, supporting bech32 and bech32m and
per-chain payload rules (BTC segwit version/program-length/padding; ML
delegated to the daemon).

Send flows (ML balances modal, BTC page) now offer an explicit
"Did you mean <address>? (fixed N characters) [Use suggested address]"
prompt when a checksum fails recoverably. The input is never mutated
without the user clicking - up to 2 typos can alias to a different
valid address, so confirmation is mandatory. Uncorrectable failures
(insert/delete, 3+ errors, charset violations) keep the existing
server-side error path.

15 unit tests: typo matrix on live ML/BTC addresses (1/2 substitutions,
3+, insert/delete, charset, mixed case, wrong hrp, bech32m v1, payload
rules, hrp mapping). Verified end-to-end in the VM browser.
@erubboli
erubboli force-pushed the feat/bitcoin-node-wallet branch from 7993b86 to 96aae2f Compare September 2, 2026 12:38
New /bridge page bridging ERC20 tokens between Ethereum and Mintlayer
via the bridge agents (api.bridge.mintlayer.org, 2-of-2 multisig):

- E2M: MetaMask (EIP-1193) connects, approves the ERC20 and calls
  MintlayerBridge.deposit(token, amount, mlAddress); the bridge request
  is submitted with the deposit tx hash. ML tokens land in the GUI's own
  ML wallet address (pre-filled from the wallet, one click to use).
- M2E: the signed-but-unbroadcast intent transaction is created
  server-side via wallet-rpc-daemon's token_make_tx_to_send_with_intent
  (token_id + deposit destination resolved from the live agents-config
  server-side, never trusted from the client); the browser submits it
  with the intent to /bridge-request. ERC20 lands at the user's
  MetaMask address.
- Requests poll until completed/failed with per-phase UX (approve,
  deposit, signing, submitting, agent statuses).
- bridge-sdk ported from bridge-frontend (dependency-free fetch client,
  injected-fetch design kept for testability); ethers v6 added for the
  EVM interactions; public explorer link + live fee display.

Design matches the GUI (dark cards, mint accents, mono addresses).
UI verified against the live bridge API (config, fees, both
directions' forms); MetaMask signing requires a browser with the
extension installed.
- Fee line computes the total for the entered amount
  (fixed + amount x pct/100, BigInt fixed-point): "Total fee X crv
  (fixed F crv + P% of amount)"; falls back to the fixed/pct breakdown
  when no amount is entered. percentage_fee arrives from the API with
  its percent sign, which is now normalized before display and math.
- E2M Mintlayer receiver gets the same bech32 typo recovery as the
  send forms (up to 2 characters, explicit "Use suggested address").
- M2E receiver validates the 0x… format client-side.
- Removed the bridge-agents info notice and the "Powered by" subtitle.
The bridge agents serve the mainnet Ethereum flavor; on testnet
deployments the section was dead weight. Gate the nav item, command
palette entry and /bridge page behind NETWORK=mainnet — the page
redirects to the dashboard elsewhere.
The new bridge files shipped untested and pulled CI line coverage below
the 80% gate (79.29%). Adds:

- bridge-sdk tests: URL building, POST payload for bridge requests,
  uuid escaping, API error mapping, filter serialization
- ml-intent-tx route tests: validation, unsupported tokens, config
  unavailability, wallet RPC error mapping, happy path

Coverage back to 85.31% lines / 89.39% branches.
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.

2 participants