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/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
+
+ )}
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 {
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);
+ }
+}
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