From f4f93f929170d6a493b02c3aae0553bc4097ff45 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Sun, 18 Oct 2026 17:44:14 +0200 Subject: [PATCH 1/4] feat: expose pruned node state and guard restores in the btc sidecar --- bdk-wallet/src/main.rs | 123 +++++++++++++++++++++++++++++++++++------ 1 file changed, 106 insertions(+), 17 deletions(-) diff --git a/bdk-wallet/src/main.rs b/bdk-wallet/src/main.rs index ab51768..0aa0168 100644 --- a/bdk-wallet/src/main.rs +++ b/bdk-wallet/src/main.rs @@ -456,6 +456,26 @@ async fn create_wallet( req.scan_from.unwrap_or(0) }; + // A pruned node cannot serve blocks below its prune window, so a + // full-history restore would silently miss coins. Refuse instead. + if created_at == 0 { + match node_rpc(&state, "getblockchaininfo", json!([])).await { + Ok(info) => { + if info.get("pruned").and_then(|b| b.as_bool()).unwrap_or(false) { + return ( + axum::http::StatusCode::CONFLICT, + Json(json!({ + "ok": false, + "error": "This Bitcoin node keeps only recent blocks (pruned mode), so it cannot scan the full history an old seed may need. Switch to a full node (BITCOIN_PRUNE=0, BITCOIN_TXINDEX=1, then resync) before restoring this seed.", + })), + ) + .into_response(); + } + } + Err(_) => { /* unreachable node: let the sync attempt surface it */ } + } + } + if let Err(e) = save_seed(&state.seed_path(), &mnemonic, state.config.network, created_at) { return internal_error(e.context("persisting seed")); } @@ -485,6 +505,21 @@ async fn sync(State(state): State>) -> Response { } let st = Arc::clone(&state); tokio::spawn(async move { + // A full-history restore (sync horizon 0) on a pruned node would + // silently miss coins. Better no balance than a wrong one: refuse + // the scan until the node can actually serve the whole history. + let horizon = *st.sync_start_time.lock().unwrap_or_else(|p| p.into_inner()); + if horizon == 0 { + if let Ok(info) = node_rpc(&st, "getblockchaininfo", json!([])).await { + if info.get("pruned").and_then(|b| b.as_bool()).unwrap_or(false) { + error!( + "wallet restore needs the full chain but the node is pruned - sync refused; switch to a full node (BITCOIN_PRUNE=0, BITCOIN_TXINDEX=1) and resync" + ); + st.syncing.store(false, Ordering::SeqCst); + return; + } + } + } let res = spawn_blocking(move || -> Result<()> { let start_time = *st.sync_start_time.lock().unwrap_or_else(|p| p.into_inner()); let chain = RpcBlockchain::from_config(&st.config.rpc_config(start_time))?; @@ -508,16 +543,7 @@ async fn sync(State(state): State>) -> Response { /// GET /status - node + wallet overview for the Settings page. async fn status(State(state): State>) -> Response { let chain = node_rpc(&state, "getblockchaininfo", json!([])).await; - let (blocks, headers, ibd) = match &chain { - Ok(info) => ( - info.get("blocks").and_then(|b| b.as_u64()).unwrap_or(0), - info.get("headers").and_then(|b| b.as_u64()).unwrap_or(0), - info.get("initialblockdownload") - .and_then(|b| b.as_bool()) - .unwrap_or(false), - ), - Err(_) => (0, 0, false), - }; + let node_json = chain_node_json(chain.as_ref().ok()); let wallet_exists = state.seed_path().exists(); let wallet = state.wallet.lock().await; @@ -537,18 +563,39 @@ async fn status(State(state): State>) -> Response { "network": network_name(state.config.network), "walletExists": wallet_exists, "walletLoaded": wallet_loaded, - "node": { - "reachable": chain.is_ok(), - "blocks": blocks, - "headers": headers, - "synced": !ibd && blocks == headers, - "initialBlockDownload": ibd, - }, + "node": node_json, "balance": balance, })) .into_response() } +/// The `node` object of /status: reachability + sync progress + whether the +/// node is pruned (which decides if full-history seed restores are possible). +fn chain_node_json(chain: Option<&serde_json::Value>) -> serde_json::Value { + let (reachable, blocks, headers, ibd, pruned, prune_height, txindex) = match chain { + Some(info) => ( + true, + info.get("blocks").and_then(|b| b.as_u64()).unwrap_or(0), + info.get("headers").and_then(|b| b.as_u64()).unwrap_or(0), + info.get("initialblockdownload").and_then(|b| b.as_bool()).unwrap_or(false), + info.get("pruned").and_then(|b| b.as_bool()).unwrap_or(false), + info.get("pruneheight").and_then(|b| b.as_u64()), + info.get("txindex").and_then(|b| b.as_bool()), + ), + None => (false, 0, 0, false, false, None, None), + }; + json!({ + "reachable": reachable, + "blocks": blocks, + "headers": headers, + "synced": reachable && !ibd && blocks == headers, + "initialBlockDownload": ibd, + "pruned": pruned, + "pruneHeight": prune_height, + "txindex": txindex, + }) +} + /// GET /balance - totals in satoshis (strings). async fn balance(State(state): State>) -> Response { let wallet = state.wallet.lock().await; @@ -861,3 +908,45 @@ mod tests { assert_eq!(classify_send_error("NoSpaceLeft { path: \"/data/wallet.sqlite\" }"), None); } } + +#[cfg(test)] +mod chain_info_tests { + use super::chain_node_json; + use serde_json::json; + + #[test] + fn full_node_shape() { + let info = json!({ + "blocks": 900_000u64, "headers": 900_000u64, + "initialblockdownload": false, "pruned": false, + "pruneheight": serde_json::Value::Null, "txindex": true, + }); + let node = chain_node_json(Some(&info)); + assert_eq!(node["reachable"], true); + assert_eq!(node["synced"], true); + assert_eq!(node["pruned"], false); + assert_eq!(node["txindex"], true); + assert!(node["pruneHeight"].is_null()); + } + + #[test] + fn pruned_node_shape() { + let info = json!({ + "blocks": 900_000u64, "headers": 900_001u64, + "initialblockdownload": true, "pruned": true, "pruneheight": 840_000u64, + }); + let node = chain_node_json(Some(&info)); + assert_eq!(node["pruned"], true); + assert_eq!(node["pruneHeight"], 840_000u64); + assert_eq!(node["synced"], false); + assert!(node["txindex"].is_null()); + } + + #[test] + fn unreachable_node_shape() { + let node = chain_node_json(None); + assert_eq!(node["reachable"], false); + assert_eq!(node["pruned"], false); + assert_eq!(node["synced"], false); + } +} From 3efbfd68111a0b169937c9f14ce6d1e91922c6a0 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Sun, 18 Oct 2026 17:44:14 +0200 Subject: [PATCH 2/4] fix: refuse pruned seed restores before unlocking keys --- app/src/lib/bitcoin-wallet.ts | 5 ++++ app/src/pages/api/bitcoin/api.test.ts | 2 +- app/src/pages/api/bitcoin/link-seed.test.ts | 26 +++++++++++++++++++++ app/src/pages/api/bitcoin/link-seed.ts | 24 ++++++++++++++++++- 4 files changed, 55 insertions(+), 2 deletions(-) diff --git a/app/src/lib/bitcoin-wallet.ts b/app/src/lib/bitcoin-wallet.ts index 2b19a6e..fb70faf 100644 --- a/app/src/lib/bitcoin-wallet.ts +++ b/app/src/lib/bitcoin-wallet.ts @@ -63,6 +63,11 @@ export interface BitcoinNodeInfo { headers: number; synced: boolean; initialBlockDownload: boolean; + /** True when the node discards old blocks — full-history restores are impossible. */ + pruned: boolean; + /** First available block on a pruned node; null on a full node. */ + pruneHeight: number | null; + txIndex: boolean | null; } export interface BitcoinStatus { diff --git a/app/src/pages/api/bitcoin/api.test.ts b/app/src/pages/api/bitcoin/api.test.ts index 50ea621..df167e7 100644 --- a/app/src/pages/api/bitcoin/api.test.ts +++ b/app/src/pages/api/bitcoin/api.test.ts @@ -60,7 +60,7 @@ describe('GET /api/bitcoin/overview', () => { it('aggregates status, address, balance and transactions', async () => { const statusPayload = { ok: true, network: "mainnet" as const, walletExists: true, walletLoaded: true, - node: { reachable: true, blocks: 1, headers: 1, synced: true, initialBlockDownload: false }, + node: { reachable: true, blocks: 1, headers: 1, synced: true, initialBlockDownload: false, pruned: false, pruneHeight: null, txIndex: true }, balance: { confirmed: '1', trustedPending: '0', untrustedPending: '0', immature: '0' }, }; vi.mocked(getBitcoinStatus).mockResolvedValue(statusPayload); diff --git a/app/src/pages/api/bitcoin/link-seed.test.ts b/app/src/pages/api/bitcoin/link-seed.test.ts index d657a46..7c34c5f 100644 --- a/app/src/pages/api/bitcoin/link-seed.test.ts +++ b/app/src/pages/api/bitcoin/link-seed.test.ts @@ -17,6 +17,7 @@ const lockPrivateKeys = vi.fn(); const unlockPrivateKeys = vi.fn(); const showSeedPhrase = vi.fn(); const createFromSeed = vi.fn(); +const getBitcoinStatus = vi.fn(); vi.mock('@/lib/wallet-rpc', () => ({ walletLockPrivateKeys: (...a: unknown[]) => lockPrivateKeys(...(a as [])), @@ -29,6 +30,7 @@ vi.mock('@/lib/wallet-rpc', () => ({ vi.mock('@/lib/bitcoin-wallet', () => ({ isBitcoinEnabled: vi.fn(() => true), createBitcoinWalletFromSeedWithRetry: (...a: unknown[]) => createFromSeed(...(a as [])), + getBitcoinStatus: (...a: unknown[]) => getBitcoinStatus(...(a as [])), })); function req(body: unknown): Parameters[0] { @@ -50,6 +52,30 @@ describe('POST /api/bitcoin/link-seed', () => { lockPrivateKeys.mockResolvedValue(undefined); showSeedPhrase.mockResolvedValue({ seed_phrase: SEED, passphrase: null }); createFromSeed.mockResolvedValue({ ok: true, created: true, network: 'mainnet' }); + getBitcoinStatus.mockResolvedValue({ + ok: true, + node: { reachable: true, pruned: false, pruneHeight: null, txIndex: true, blocks: 1, headers: 1, synced: true, initialBlockDownload: false }, + }); + }); + + it('refuses before unlocking keys when the node is pruned', async () => { + getBitcoinStatus.mockResolvedValue({ + ok: true, + node: { reachable: true, pruned: true, pruneHeight: 840000, txIndex: null, blocks: 900000, headers: 900000, synced: true, initialBlockDownload: false }, + }); + const res = await POST(req({ password: 'hunter2', totp_code: '123456' })); + expect(res.status).toBe(409); + const body = (await res.json()) as { error: string }; + expect(body.error).toMatch(/pruned/i); + expect(unlockPrivateKeys).not.toHaveBeenCalled(); + expect(showSeedPhrase).not.toHaveBeenCalled(); + }); + + it('proceeds when the node-status probe fails (sidecar still guards)', async () => { + getBitcoinStatus.mockRejectedValue(new Error('sidecar down')); + const res = await POST(req({ password: 'hunter2', totp_code: '123456' })); + expect(res.status).toBe(200); + expect(unlockPrivateKeys).toHaveBeenCalled(); }); it('links: unlocks, reveals, derives the wallet, re-locks', async () => { diff --git a/app/src/pages/api/bitcoin/link-seed.ts b/app/src/pages/api/bitcoin/link-seed.ts index dcec710..d7711b2 100644 --- a/app/src/pages/api/bitcoin/link-seed.ts +++ b/app/src/pages/api/bitcoin/link-seed.ts @@ -1,6 +1,10 @@ import type { APIRoute } from 'astro'; import { json } from '@/lib/api-utils'; -import { isBitcoinEnabled, createBitcoinWalletFromSeedWithRetry } from '@/lib/bitcoin-wallet'; +import { + isBitcoinEnabled, + createBitcoinWalletFromSeedWithRetry, + getBitcoinStatus, +} from '@/lib/bitcoin-wallet'; import { requireStepUp, totpFromBody } from '@/lib/step-up'; import { walletShowSeedPhrase, @@ -45,6 +49,24 @@ export const POST: APIRoute = async ({ request, clientAddress }) => { return json({ ok: false, error: 'Your Mintlayer wallet password is required' }, 400); } + // A pruned node can't serve the full history this restore needs — fail + // fast BEFORE unlocking private keys for a scan that would miss coins. + try { + const status = await getBitcoinStatus(); + if (status.node.pruned) { + return json( + { + ok: false, + error: + 'This Bitcoin node keeps only recent blocks (pruned mode), so it cannot scan the full history your seed may need. Switch to a full node (BITCOIN_PRUNE=0, BITCOIN_TXINDEX=1, then resync) and try again.', + }, + 409, + ); + } + } catch { + // Status probe failed — the sidecar's own guard still applies later. + } + let seed = ''; try { try { From b8705f3b2f175c4b868120f897a819a211ad27c1 Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Sun, 18 Oct 2026 17:44:14 +0200 Subject: [PATCH 3/4] feat: show the bitcoin node mode in the wallet page --- app/src/components/BitcoinWallet.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/components/BitcoinWallet.tsx b/app/src/components/BitcoinWallet.tsx index 8c1ec36..1d72392 100644 --- a/app/src/components/BitcoinWallet.tsx +++ b/app/src/components/BitcoinWallet.tsx @@ -353,6 +353,14 @@ export default function BitcoinWallet({ explorerUrl }: { explorerUrl?: string | {overview.status && ( {overview.status.network} )} + {node?.reachable && node.pruned && ( + + pruned node + + )} From a40d0cce8fb163a2e334b6981182e72d698c042b Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Sun, 18 Oct 2026 17:44:14 +0200 Subject: [PATCH 4/4] docs: document bitcoin pruned mode --- README.md | 8 +++++++- env.dev.example | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5169875..ba6e130 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,13 @@ How it works: - Mainnet chain data is roughly **700 GB** with the default `txindex=1`. The first sync can take days. Use `BITCOIN_NETWORK=testnet` (or `regtest`) to try it out cheaply. - The BTC wallet is a **hot wallet** — keep only spending amounts on it. -- Pruning (`BITCOIN_PRUNE`) is incompatible with the wallet's history sync; leave it off. +- **Storage modes** — `./init.sh` asks; the default is pruned: + - **Full** (`BITCOIN_PRUNE=0`, `BITCOIN_TXINDEX=1`, ~700 GB): complete history — restoring + any old seed rescans everything. + - **Pruned** (`BITCOIN_PRUNE=550`, `BITCOIN_TXINDEX=0`, ~15–25 GB): keeps only recent + blocks. Wallets created from now work normally (balance, receive, send); **restoring an + old seed is refused** — a pruned node cannot rescan history it no longer stores, and a + partial scan would silently miss coins. Switching back to full requires a full resync. - Bitcoin Core is pinned to **25.x**: the BDK rpc backend cannot parse the `warnings` format used by Core 26+. Override only if you know what you are doing (`BITCOIND_IMAGE`). diff --git a/env.dev.example b/env.dev.example index 30cd74d..ffa5111 100644 --- a/env.dev.example +++ b/env.dev.example @@ -20,6 +20,9 @@ WALLET_RPC_USERNAME=dev WALLET_RPC_PASSWORD=dev # Optional Bitcoin profile (make bitcoin): bitcoind + BDK sidecar. +# Default (below) is a full node — mainnet costs ~700 GB. For a light dev +# setup use pruned mode instead (~15-25 GB): BITCOIN_TXINDEX=0 + BITCOIN_PRUNE=550 +# (wallets created afterwards work normally; old-seed restores need a full node). BITCOIN_RPC_USERNAME=dev-btc BITCOIN_RPC_PASSWORD=dev-btc BITCOIN_WALLET_HTTP_USERNAME=dev-btc-wallet