Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).

Expand Down
8 changes: 8 additions & 0 deletions app/src/components/BitcoinWallet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,14 @@ export default function BitcoinWallet({ explorerUrl }: { explorerUrl?: string |
{overview.status && (
<span className="uppercase tracking-wider text-gray-500">{overview.status.network}</span>
)}
{node?.reachable && node.pruned && (
<span
title={`Pruned node: keeps blocks from #${(node.pruneHeight ?? 0).toLocaleString()} onward. Creating a wallet works normally, but restoring an old seed needs a full node.`}
className="rounded border border-gray-700 px-1.5 py-0.5 text-[10px] text-gray-400"
>
pruned node
</span>
)}
<button onClick={refresh} disabled={busy} className="ml-auto text-mint-400 hover:text-mint-300 disabled:opacity-50">
Refresh
</button>
Expand Down
5 changes: 5 additions & 0 deletions app/src/lib/bitcoin-wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion app/src/pages/api/bitcoin/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
26 changes: 26 additions & 0 deletions app/src/pages/api/bitcoin/link-seed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [])),
Expand All @@ -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<typeof POST>[0] {
Expand All @@ -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 () => {
Expand Down
24 changes: 23 additions & 1 deletion app/src/pages/api/bitcoin/link-seed.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
123 changes: 106 additions & 17 deletions bdk-wallet/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}
Expand Down Expand Up @@ -485,6 +505,21 @@ async fn sync(State(state): State<Arc<AppState>>) -> 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))?;
Expand All @@ -508,16 +543,7 @@ async fn sync(State(state): State<Arc<AppState>>) -> Response {
/// GET /status - node + wallet overview for the Settings page.
async fn status(State(state): State<Arc<AppState>>) -> 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;
Expand All @@ -537,18 +563,39 @@ async fn status(State(state): State<Arc<AppState>>) -> 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<Arc<AppState>>) -> Response {
let wallet = state.wallet.lock().await;
Expand Down Expand Up @@ -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);
}
}
3 changes: 3 additions & 0 deletions env.dev.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading