From 0f85f588fbb7e93d09c4fc60276f0f53058c8279 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 29 Sep 2026 14:42:44 -0400 Subject: [PATCH] Fix Cashu file redemption and Bitcoin-dependent wallet readiness --- CHANGELOG.md | 10 + apps/bitcoin-core/manifest.yml | 2 +- apps/bitcoin-knots/manifest.yml | 2 +- core/Cargo.lock | 2 +- core/archipelago/Cargo.toml | 2 +- core/archipelago/src/api/handler/proxy.rs | 13 + core/archipelago/src/api/rpc/content.rs | 85 ++-- core/archipelago/src/api/rpc/lnd/info.rs | 84 ++++ core/archipelago/src/api/rpc/lnd/mod.rs | 63 ++- .../src/api/rpc/package/install.rs | 4 + .../src/api/rpc/package/set_config.rs | 10 + core/archipelago/src/bitcoin_status.rs | 24 +- core/archipelago/src/container/lnd.rs | 104 +++++ .../src/container/prod_orchestrator.rs | 60 ++- core/archipelago/src/content_server.rs | 46 +- .../src/settings/bitcoin_storage.rs | 51 +++ core/archipelago/src/settings/mod.rs | 2 + core/archipelago/src/wallet/ecash.rs | 107 ++--- core/archipelago/src/wallet/mint_client.rs | 109 +++-- core/archipelago/src/wallet/payment_tests.rs | 428 ++++++++++++++++++ docker/lnd-ui/index.html | 54 ++- docs/TODO.md | 19 +- docs/incident-framework-lnd-startup.md | 17 +- docs/repair-release-20260929.md | 112 +++++ neode-ui/package.json | 2 +- neode-ui/src/api/rpc-client.ts | 1 + .../src/components/BitcoinPruningChoice.vue | 21 + .../src/components/ExternalExplorerModal.vue | 2 +- .../src/components/InstallVersionModal.vue | 16 +- .../src/components/WalletSettingsModal.vue | 2 +- .../__tests__/InstallVersionModal.test.ts | 67 +++ .../__tests__/useTxExplorer.test.ts | 38 ++ neode-ui/src/composables/useTxExplorer.ts | 12 +- neode-ui/src/views/Discover.vue | 23 +- neode-ui/src/views/MarketplaceAppDetails.vue | 18 +- .../src/views/settings/AccountInfoSection.vue | 16 + scripts/container-doctor.sh | 53 ++- tests/regression/bitcoin-prune-entrypoint.py | 33 ++ tests/regression/container-doctor-ports.sh | 40 ++ tests/regression/lnd-ui-readiness.cjs | 94 ++++ tests/release/run.sh | 7 +- 41 files changed, 1641 insertions(+), 214 deletions(-) create mode 100644 core/archipelago/src/settings/bitcoin_storage.rs create mode 100644 core/archipelago/src/wallet/payment_tests.rs create mode 100644 docs/repair-release-20260929.md create mode 100644 neode-ui/src/components/BitcoinPruningChoice.vue create mode 100644 neode-ui/src/components/__tests__/InstallVersionModal.test.ts create mode 100644 tests/regression/bitcoin-prune-entrypoint.py create mode 100644 tests/regression/container-doctor-ports.sh create mode 100644 tests/regression/lnd-ui-readiness.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index e8c5d070..fcd434e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +## v1.8.20-alpha (2026-09-29) + +- Fixed Cashu file payments rejected despite a shared mint, and preserved the payment amount when mint fees reduce change. +- Payment failures now report whether a refund actually succeeded; missing files and unsupported payment methods are rejected before charging. +- Bitcoin Core and Knots installation offers optional pruning on larger disks, using the same settings as automatic pruning. +- Fixed false missing-port checks that unnecessarily restarted Bitcoin and LND; recovery now respects managed shutdown timeouts. +- LND explains when it is waiting for Bitcoin installation, startup, or sync, without treating normal synchronization as a restart-worthy failure. +- Bitcoin startup messages explain block-index loading without exposing raw RPC errors, and Lightning keeps known balances clearly marked during outages. +- Changed the public transaction-explorer default to mempool.space while preserving local explorers and custom choices. + ## v1.8.19-alpha (2026-09-28) - Fixed the embedded AIUI chat page painting a second background and dark scrim over Archy’s dashboard background. diff --git a/apps/bitcoin-core/manifest.yml b/apps/bitcoin-core/manifest.yml index a14a50ea..6a13168c 100644 --- a/apps/bitcoin-core/manifest.yml +++ b/apps/bitcoin-core/manifest.yml @@ -54,7 +54,7 @@ app: if [ -n "$RPC_TXRELAY_AUTH" ]; then RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips"; fi; - if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then + if [ "${BITCOIN_PRUNE:-0}" = "1" ] || [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS; else exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS; diff --git a/apps/bitcoin-knots/manifest.yml b/apps/bitcoin-knots/manifest.yml index 5545fad1..4dd080af 100644 --- a/apps/bitcoin-knots/manifest.yml +++ b/apps/bitcoin-knots/manifest.yml @@ -60,7 +60,7 @@ app: if [ -n "$RPC_TXRELAY_AUTH" ]; then RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips"; fi; - if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then + if [ "${BITCOIN_PRUNE:-0}" = "1" ] || [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS; else exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS; diff --git a/core/Cargo.lock b/core/Cargo.lock index f44ec9e0..bc48130c 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -104,7 +104,7 @@ dependencies = [ [[package]] name = "archipelago" -version = "1.8.19-alpha" +version = "1.8.20-alpha" dependencies = [ "anyhow", "archipelago-container", diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index d920c578..de06899a 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "archipelago" -version = "1.8.19-alpha" +version = "1.8.20-alpha" edition = "2021" license.workspace = true description = "Archipelago Bitcoin Node OS - Native backend" diff --git a/core/archipelago/src/api/handler/proxy.rs b/core/archipelago/src/api/handler/proxy.rs index 7ddceb4e..cda46c47 100644 --- a/core/archipelago/src/api/handler/proxy.rs +++ b/core/archipelago/src/api/handler/proxy.rs @@ -138,6 +138,19 @@ impl ApiHandler { cors_origin: &str, ) -> Result> { let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/"); + if suffix == "/archy-status" { + return Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .header("Cache-Control", "no-store") + .header("Access-Control-Allow-Origin", cors_origin) + .header("Access-Control-Allow-Credentials", "true") + .header("Vary", "Origin") + .body(hyper::Body::from( + rpc.handle_lnd_readiness().await.to_string(), + ))?); + } + let url = format!("{LND_REST_BASE_URL}{suffix}"); // LND REST serves a self-signed cert and requires the admin macaroon. // A bare reqwest::get() uses the default client, which rejects the diff --git a/core/archipelago/src/api/rpc/content.rs b/core/archipelago/src/api/rpc/content.rs index 6eacf538..be4c2445 100644 --- a/core/archipelago/src/api/rpc/content.rs +++ b/core/archipelago/src/api/rpc/content.rs @@ -22,9 +22,9 @@ const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-cata /// Best-effort reclaim of an ecash payment token that was minted but the sale /// didn't complete (seller unreachable or couldn't redeem it), so the buyer /// doesn't lose the value. For Fedimint the spender can reissue its own -/// un-redeemed notes; for Cashu the proofs are received back. Fails silently if -/// the seller already claimed the token (then the value is genuinely gone). -async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) { +/// un-redeemed notes; for Cashu the proofs are received back. Report the actual +/// recovered amount, or explicitly say when a refund could not be confirmed. +async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) -> String { let res = match backend { "fedimint" => crate::wallet::fedimint_client::reissue_into_any(data_dir, token) .await @@ -32,13 +32,14 @@ async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: & _ => ecash::receive_token(data_dir, token).await, }; match res { - Ok(sats) => tracing::info!( - "paid download: reclaimed {sats} sats of unspent {backend} ecash after a failed sale" - ), - Err(e) => tracing::warn!( - "paid download: could not reclaim {backend} ecash (the peer may have already \ - claimed it): {e:#}" - ), + Ok(sats) => { + tracing::info!("paid download: reclaimed {sats} sats after failed sale"); + format!("Refunded {sats} sats to your wallet.") + } + Err(e) => { + tracing::warn!("paid download: refund not confirmed: {e}"); + "Your refund could not be confirmed. The seller may have received the payment. Do not pay again until this is checked.".to_string() + } } } @@ -547,29 +548,27 @@ impl RpcHandler { // Surface a real reason instead of the generic sanitized error (#30): // the dial already tries FIPS/mesh then falls back to Tor, so a failure // here means the peer is genuinely unreachable on both transports. - let (response, transport) = match crate::fips::dial::PeerRequest::new( - fips_npub.as_deref(), - onion, - &path, - ) - .service(crate::settings::transport::PeerService::PeerFiles) - .header("X-Federation-DID", local_did) - .header("X-Payment-Token", token_str.clone()) - .timeout(std::time::Duration::from_secs(900)) - .send_get() - .await - { - Ok(v) => v, - Err(e) => { - tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e); - // The token was already minted/spent — reclaim it so the buyer - // doesn't lose the value when the seller was simply unreachable. - reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await; - return Ok(serde_json::json!({ - "error": "Could not reach the peer over mesh or Tor — it may be offline. Your ecash was refunded to your wallet. Please try again." - })); - } - }; + let (response, transport) = + match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path) + .service(crate::settings::transport::PeerService::PeerFiles) + .header("X-Federation-DID", local_did) + .header("X-Payment-Token", token_str.clone()) + .timeout(std::time::Duration::from_secs(900)) + .send_get() + .await + { + Ok(v) => v, + Err(e) => { + tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e); + // The token was already minted/spent — reclaim it so the buyer + // doesn't lose the value when the seller was simply unreachable. + let refund = + reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await; + return Ok(serde_json::json!({ + "error": format!("Could not reach the peer over mesh or Tor. {refund}") + })); + } + }; // Record which transport actually reached the peer (B14). if let Err(e) = crate::federation::record_peer_transport( &self.config.data_dir, @@ -583,25 +582,17 @@ impl RpcHandler { } if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED { - // Payment was rejected by the seller. Surface the most likely cause - // per backend — for ecash both sides must share a redemption network - // (a Cashu mint, or a Fedimint federation). + // A 402 can mean mint validation, network failure, underpayment, + // or an unaccepted mint. Do not invent a mint-mismatch diagnosis. let body = response.text().await.unwrap_or_default(); tracing::warn!( "paid download: seller {onion} rejected {used_backend} payment of {price_sats} sats: {body}" ); // Seller couldn't redeem the token — reclaim it so the buyer keeps // their funds (the spent-but-unredeemed-notes case the user hit). - reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await; - let hint = match used_backend { - "fedimint" => "the seller isn't in the same Fedimint federation as you", - _ => "the seller doesn't accept your Cashu mint", - }; + let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await; return Ok(serde_json::json!({ - "error": format!( - "Payment rejected by the seller — {hint}. Your ecash was refunded to \ - your wallet. Try the other ecash type, or use a shared mint/federation." - ) + "error": format!("The seller could not verify the payment. {refund}") })); } @@ -609,9 +600,9 @@ impl RpcHandler { let status = response.status(); let body = response.text().await.unwrap_or_default(); tracing::warn!("paid download: seller {onion} returned {status}: {body}"); - reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await; + let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await; return Ok(serde_json::json!({ - "error": format!("Peer returned an error ({status}). Your ecash was refunded to your wallet.") + "error": format!("Peer returned an error ({status}). {refund}") })); } diff --git a/core/archipelago/src/api/rpc/lnd/info.rs b/core/archipelago/src/api/rpc/lnd/info.rs index ea12108f..6d94a6c5 100644 --- a/core/archipelago/src/api/rpc/lnd/info.rs +++ b/core/archipelago/src/api/rpc/lnd/info.rs @@ -109,7 +109,50 @@ fn checked_balances( )) } +fn bitcoin_wait_state( + installed: bool, + running: bool, + fresh: bool, + ibd: Option, +) -> (&'static str, &'static str) { + if !installed { + ("waiting_install", "Waiting for Bitcoin to be installed") + } else if !running { + ("waiting_start", "Waiting for Bitcoin to start") + } else if !fresh || ibd.is_none() { + ("waiting_start", "Waiting for Bitcoin to start") + } else if ibd == Some(true) { + ("waiting_sync", "Waiting for Bitcoin to sync") + } else { + ("bitcoin_ready", "Bitcoin is ready") + } +} + impl RpcHandler { + pub(crate) async fn handle_lnd_readiness(&self) -> serde_json::Value { + let (data, _) = self.state_manager.get_snapshot().await; + if !data.server_info.status_info.containers_scanned { + return serde_json::json!({"state":"checking", "message":"Checking Bitcoin availability"}); + } + let nodes: Vec<_> = ["bitcoin-core", "bitcoin-knots", "bitcoin"] + .iter() + .filter_map(|id| data.package_data.get(*id)) + .collect(); + let installed = !nodes.is_empty(); + let running = nodes + .iter() + .any(|p| p.state == crate::data_model::PackageState::Running); + let bitcoin = crate::bitcoin_status::get_bitcoin_status().await; + let ibd = bitcoin + .blockchain_info + .as_ref() + .and_then(|v| v.get("initialblockdownload")) + .and_then(|v| v.as_bool()); + let (state, message) = + bitcoin_wait_state(installed, running, bitcoin.ok && !bitcoin.stale, ibd); + serde_json::json!({"state": state, "message": message}) + } + pub(in crate::api::rpc) async fn handle_lnd_getinfo(&self) -> Result { let macaroon_bytes = read_lnd_admin_macaroon().await?; let macaroon_hex = hex::encode(&macaroon_bytes); @@ -419,3 +462,44 @@ mod tests { assert!(!is_valid_identity_pubkey(&"g".repeat(66))); } } + +#[cfg(test)] +mod dependency_readiness_tests { + use super::bitcoin_wait_state; + #[test] + fn waiting_states_cover_install_start_sync_outage_and_recovery() { + assert_eq!( + bitcoin_wait_state(false, false, false, None).0, + "waiting_install" + ); + assert_eq!( + bitcoin_wait_state(true, false, false, None).0, + "waiting_start" + ); + assert_eq!( + bitcoin_wait_state(true, true, false, None).0, + "waiting_start" + ); + assert_eq!( + bitcoin_wait_state(true, true, true, Some(true)).0, + "waiting_sync" + ); + assert_eq!( + bitcoin_wait_state(true, true, true, Some(false)).0, + "bitcoin_ready" + ); + // Previously synced cached information must not hide a current outage. + assert_eq!( + bitcoin_wait_state(true, true, false, Some(false)).0, + "waiting_start" + ); + assert_eq!( + bitcoin_wait_state(true, true, true, None).0, + "waiting_start" + ); + assert_eq!( + bitcoin_wait_state(true, true, true, Some(false)).0, + "bitcoin_ready" + ); + } +} diff --git a/core/archipelago/src/api/rpc/lnd/mod.rs b/core/archipelago/src/api/rpc/lnd/mod.rs index c2909968..ef734ccf 100644 --- a/core/archipelago/src/api/rpc/lnd/mod.rs +++ b/core/archipelago/src/api/rpc/lnd/mod.rs @@ -133,12 +133,36 @@ async fn stream_lnd_transactions(sm: &crate::state::StateManager) -> Result<()> /// RPC-unreachable and locked-wallet states are deliberately NOT handled /// here — container-down is crash-recovery's job, and unlocking needs the /// operator. +fn bitcoin_ready_for_lnd_watchdog(status: &crate::bitcoin_status::BitcoinNodeStatus) -> bool { + status.ok + && !status.stale + && status.age_ms < 30_000 + && status + .blockchain_info + .as_ref() + .and_then(|v| v.get("initialblockdownload")) + .and_then(|v| v.as_bool()) + == Some(false) +} + pub(crate) fn spawn_lnd_health_watchdog() { tokio::spawn(async move { let mut bad_minutes: u32 = 0; let mut last_restart: Option = None; + let mut last_height: Option = None; loop { tokio::time::sleep(std::time::Duration::from_secs(60)).await; + // Initial Bitcoin sync, warmup, and outages are dependencies to + // wait for, never evidence that LND is wedged. Do not accumulate + // restart pressure during a days-long initial block download. + let bitcoin = crate::bitcoin_status::get_bitcoin_status().await; + if !bitcoin_ready_for_lnd_watchdog(&bitcoin) + || crate::app_ops::lifecycle_op_in_flight("lnd") + { + bad_minutes = 0; + last_height = None; + continue; + } let Ok(bytes) = read_lnd_admin_macaroon().await else { bad_minutes = 0; // no LND on this node (or not set up yet) continue; @@ -161,6 +185,10 @@ pub(crate) fn spawn_lnd_health_watchdog() { bad_minutes = 0; // down/locked — not the wedge signature continue; }; + if !resp.status().is_success() { + bad_minutes = 0; + continue; + } let Ok(info) = resp.json::().await else { bad_minutes = 0; continue; @@ -182,7 +210,12 @@ pub(crate) fn spawn_lnd_health_watchdog() { .get("num_pending_channels") .and_then(|v| v.as_u64()) .unwrap_or(0); - let wedged = !synced || (channels > 0 && peers == 0); + let height = info.get("block_height").and_then(|v| v.as_u64()); + let progressing = height + .zip(last_height) + .is_some_and(|(now, before)| now > before); + last_height = height; + let wedged = !progressing && (!synced || (channels > 0 && peers == 0)); if !wedged { bad_minutes = 0; continue; @@ -239,3 +272,31 @@ impl RpcHandler { Ok((client, macaroon_hex)) } } + +#[cfg(test)] +mod watchdog_dependency_tests { + use super::bitcoin_ready_for_lnd_watchdog; + use crate::bitcoin_status::BitcoinNodeStatus; + use serde_json::json; + #[test] + fn initial_sync_warmup_outage_stale_and_unknown_never_trigger_lnd_restart() { + let mut status = BitcoinNodeStatus::default(); + assert!(!bitcoin_ready_for_lnd_watchdog(&status)); + status.ok = true; + status.blockchain_info = Some(json!({"initialblockdownload":true})); + assert!(!bitcoin_ready_for_lnd_watchdog(&status)); + status.blockchain_info = Some(json!({"initialblockdownload":false})); + assert!(bitcoin_ready_for_lnd_watchdog(&status)); + status.stale = true; + assert!(!bitcoin_ready_for_lnd_watchdog(&status)); + status.stale = false; + status.ok = false; + assert!(!bitcoin_ready_for_lnd_watchdog(&status)); + status.ok = true; + status.age_ms = 30_000; + assert!(!bitcoin_ready_for_lnd_watchdog(&status)); + status.age_ms = 0; + status.blockchain_info = Some(json!({})); + assert!(!bitcoin_ready_for_lnd_watchdog(&status)); + } +} diff --git a/core/archipelago/src/api/rpc/package/install.rs b/core/archipelago/src/api/rpc/package/install.rs index 9ee88898..86259562 100644 --- a/core/archipelago/src/api/rpc/package/install.rs +++ b/core/archipelago/src/api/rpc/package/install.rs @@ -326,6 +326,10 @@ impl RpcHandler { // an older version pins it so install_fresh resolves that image and the // update badge stays suppressed. See docs/bitcoin-multi-version-design.md. if matches!(package_id, "bitcoin-core" | "bitcoin-knots") { + if let Some(value) = params.get("prune") { + let prune = value.as_bool().context("prune must be a boolean")?; + crate::settings::bitcoin_storage::save(&self.config.data_dir, prune).await?; + } if let Some(version) = params.get("version").and_then(|v| v.as_str()) { persist_install_version_selection(package_id, version).await; } diff --git a/core/archipelago/src/api/rpc/package/set_config.rs b/core/archipelago/src/api/rpc/package/set_config.rs index f0c50222..9bf7d3e4 100644 --- a/core/archipelago/src/api/rpc/package/set_config.rs +++ b/core/archipelago/src/api/rpc/package/set_config.rs @@ -153,8 +153,18 @@ impl RpcHandler { let default = app_catalog::catalog_default_version(app_id); let cfg = version_config::read(app_id); let installed = installed_version(app_id).await; + let bitcoin_prune = if matches!(app_id, "bitcoin-core" | "bitcoin-knots") { + Some( + crate::settings::bitcoin_storage::load(&self.config.data_dir) + .await? + .prune, + ) + } else { + None + }; Ok(serde_json::json!({ + "bitcoinPrune": bitcoin_prune, "id": app_id, "supportsVersions": supports_versions(app_id), "default": default, diff --git a/core/archipelago/src/bitcoin_status.rs b/core/archipelago/src/bitcoin_status.rs index f53ae4a4..66b4747c 100644 --- a/core/archipelago/src/bitcoin_status.rs +++ b/core/archipelago/src/bitcoin_status.rs @@ -100,7 +100,11 @@ fn friendly_transient_error(has_cached_state: bool, err_msg: &str) -> String { .trim() .trim_end_matches('.'); let lower = detail.to_lowercase(); - let state = if lower.contains("verifying blocks") { + let state = if lower.contains("loading block index") { + Some("loading its block index. This can take a while after installation or restart") + } else if lower.contains("replaying blocks") { + Some("checking saved blocks before startup completes") + } else if lower.contains("verifying blocks") { Some("verifying blocks after restart") } else if lower.contains("connection reset") { Some("starting up and not yet accepting RPC connections") @@ -340,3 +344,21 @@ mod tests { assert!(msg.len() < 260); } } + +#[cfg(test)] +mod startup_message_tests { + #[test] + fn loading_block_index_is_explained_without_rpc_error_dump() { + for cached in [false, true] { + let message = super::friendly_transient_error( + cached, + r#"getblockchaininfo: Bitcoin RPC returned 500 Internal Server Error: {"error":{"code":-28,"message":"Loading block index…"}}"#, + ); + assert!(message.contains("loading its block index")); + for raw in ["500", "-28", "Detail:", "getblockchaininfo", "{", "RPC"] { + assert!(!message.contains(raw)); + } + assert_eq!(message.contains("last known state"), cached); + } + } +} diff --git a/core/archipelago/src/container/lnd.rs b/core/archipelago/src/container/lnd.rs index 19f24ee2..4b745d57 100644 --- a/core/archipelago/src/container/lnd.rs +++ b/core/archipelago/src/container/lnd.rs @@ -89,18 +89,74 @@ bitcoind.estimatemode=ECONOMICAL\n" Ok(EnsureOutcome::Written) } +/// Bitcoin can accept TCP while returning RPC_IN_WARMUP for many minutes. +/// Unlocking LND then triggers its short chain-backend timeout and a restart loop. +/// Leave the wallet intact and locked; the next reconciliation retries readiness. +async fn bitcoin_rpc_ready() -> bool { + let (user, password) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await; + let client = match reqwest::Client::builder() + .no_proxy() + .timeout(std::time::Duration::from_secs(5)) + .build() + { + Ok(client) => client, + Err(_) => return false, + }; + let response = client.post(crate::constants::BITCOIN_RPC_URL) + .basic_auth(user, Some(password)) + .json(&serde_json::json!({"jsonrpc":"1.0","id":"lnd-readiness","method":"getblockchaininfo","params":[]})) + .send().await; + match response { + Ok(response) if response.status().is_success() => response + .json::() + .await + .is_ok_and(|value| bitcoin_readiness_response(&value)), + _ => false, + } +} + +fn bitcoin_readiness_response(value: &serde_json::Value) -> bool { + value.get("error").is_none_or(|e| e.is_null()) + && value + .pointer("/result/blocks") + .and_then(|v| v.as_u64()) + .is_some() + && value + .pointer("/result/initialblockdownload") + .and_then(|v| v.as_bool()) + .is_some() +} + pub async fn ensure_wallet_initialized() -> Result<()> { let admin_macaroon = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon"; let wallet_db = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/wallet.db"; if file_exists_as_root(wallet_db).await { + // GetInfo can wait for Bitcoin sync even though the wallet is already + // unlocked. State RPC stays available during that normal startup phase. + let client = reqwest::Client::builder() + .no_proxy() + .timeout(std::time::Duration::from_secs(5)) + .danger_accept_invalid_certs(true) + .build()?; + if wallet_is_unlocked(wallet_state(&client).await.as_deref()) { + return Ok(()); + } if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await { return Ok(()); } + if !bitcoin_rpc_ready().await { + tracing::debug!("[lnd] waiting for Bitcoin RPC readiness before wallet unlock"); + return Ok(()); + } unlock_existing_wallet_no_wipe().await?; wait_for_admin_macaroon(admin_macaroon).await?; return Ok(()); } + if !bitcoin_rpc_ready().await { + tracing::debug!("[lnd] waiting for Bitcoin RPC readiness before wallet initialization"); + return Ok(()); + } init_wallet_via_rest().await?; wait_for_admin_macaroon(admin_macaroon).await } @@ -258,6 +314,9 @@ async fn unlock_existing_wallet_via_rest() -> Result { // exactly the nodes least able to afford it. Waiting longer costs nothing — // a wrong password still exits on the first pass via `all_rejected`. for _ in 0..UNLOCK_NOT_READY_ATTEMPTS { + if wallet_is_unlocked(wallet_state(&client).await.as_deref()) { + return Ok(true); + } let mut all_rejected = true; for pw in &candidates { match try_unlock_once(&client, pw).await { @@ -294,6 +353,10 @@ pub(crate) async fn unlock_existing_wallet_no_wipe() -> Result<()> { } } +fn wallet_is_unlocked(state: Option<&str>) -> bool { + matches!(state, Some("UNLOCKED" | "RPC_ACTIVE" | "SERVER_ACTIVE")) +} + /// Current LND wallet state via the unauthenticated `/v1/state` endpoint /// (NON_EXISTING / LOCKED / UNLOCKED / RPC_ACTIVE / …). None if unreachable. async fn wallet_state(client: &reqwest::Client) -> Option { @@ -1089,3 +1152,44 @@ mod tests { .is_empty()); } } + +#[cfg(test)] +mod bitcoin_readiness_tests { + use super::bitcoin_readiness_response; + use serde_json::json; + #[test] + fn only_usable_bitcoin_rpc_allows_wallet_unlock() { + for response in [ + json!({}), + json!({"error":{"code":-28,"message":"Loading block index"},"result":null}), + json!({"result":{"blocks":null}}), + ] { + assert!(!bitcoin_readiness_response(&response)); + } + // Initial sync is supported by LND. Loading the database is not. + for ibd in [true, false] { + assert!(bitcoin_readiness_response( + &json!({"result":{"blocks":100,"initialblockdownload":ibd},"error":null}) + )); + } + } +} + +#[cfg(test)] +mod syncing_wallet_state_tests { + #[test] + fn an_unlocked_wallet_waiting_for_chain_sync_is_never_unlocked_again() { + for state in ["UNLOCKED", "RPC_ACTIVE", "SERVER_ACTIVE"] { + assert!(super::wallet_is_unlocked(Some(state))); + } + for state in [ + None, + Some("LOCKED"), + Some("NON_EXISTING"), + Some("WAITING_TO_START"), + Some("unknown"), + ] { + assert!(!super::wallet_is_unlocked(state)); + } + } +} diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index d7e6299a..712e660e 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -1913,6 +1913,11 @@ impl ProdContainerOrchestrator { .unwrap_or_default(); let mut report = ReconcileReport::default(); let disk_gb = self.disk_gb().await; + let bitcoin_pruned = disk_gb < ARCHIVAL_BITCOIN_DISK_GB + || crate::settings::bitcoin_storage::load(&self.data_dir) + .await + .map(|settings| settings.prune) + .unwrap_or(true); // Register every candidate before the (sequential, possibly slow) // pass so the scanner overlays queued-but-down apps as Restarting // instead of Stopped. Each app is deregistered as its turn finishes, @@ -1952,7 +1957,7 @@ impl ProdContainerOrchestrator { } if mode == ReconcileMode::ExistingOnly && requires_archival_bitcoin(&app_id) - && disk_gb < ARCHIVAL_BITCOIN_DISK_GB + && bitcoin_pruned { report.record( &app_id, @@ -3720,6 +3725,17 @@ impl ProdContainerOrchestrator { } let mut env = manifest.app.environment.clone(); env.extend(manifest.app.container.resolve_derived_env(&facts)); + if matches!(manifest.app.id.as_str(), "bitcoin-core" | "bitcoin-knots") { + let storage = crate::settings::bitcoin_storage::load(&self.data_dir).await?; + env.retain(|entry| !entry.starts_with("BITCOIN_PRUNE=")); + if storage.prune { + anyhow::ensure!( + manifest.app.container.custom_args.iter().any(|arg| arg.contains("BITCOIN_PRUNE")), + "This Bitcoin app definition cannot honor the pruning choice. Refresh the app catalog and try again." + ); + env.push("BITCOIN_PRUNE=1".to_string()); + } + } // FM_BITCOIND_URL now comes from the manifest's {{BITCOIN_HOST}} // derived_env (works on Knots/Core/any distro). The old hardcoded @@ -6073,6 +6089,48 @@ app: ); } + #[tokio::test] + async fn bitcoin_storage_choice_is_applied_and_old_catalog_cannot_silently_ignore_it() { + let rt = Arc::new(MockRuntime::default()); + let mut orch = orch_with(rt).await; + let dir = tempfile::tempdir().unwrap(); + orch.set_data_dir(dir.path().to_path_buf()); + for id in ["bitcoin-core", "bitcoin-knots"] { + let mut old = pull_manifest(id, "docker.io/bitcoin/bitcoin:28"); + // No preference: existing containers need no new environment flag. + crate::settings::bitcoin_storage::save(dir.path(), false) + .await + .unwrap(); + orch.resolve_dynamic_env(&mut old).await.unwrap(); + assert!(!old + .app + .environment + .iter() + .any(|s| s.starts_with("BITCOIN_PRUNE="))); + crate::settings::bitcoin_storage::save(dir.path(), true) + .await + .unwrap(); + assert!(orch + .resolve_dynamic_env(&mut old) + .await + .unwrap_err() + .to_string() + .contains("cannot honor")); + let mut current = pull_manifest(id, "docker.io/bitcoin/bitcoin:28"); + current + .app + .container + .custom_args + .push("if [ ${BITCOIN_PRUNE:-0} = 1 ]; then :; fi".into()); + orch.resolve_dynamic_env(&mut current).await.unwrap(); + assert!(current + .app + .environment + .iter() + .any(|s| s == "BITCOIN_PRUNE=1")); + } + } + #[tokio::test] async fn install_resolves_derived_and_secret_env_before_create() { let rt = Arc::new(MockRuntime::default()); diff --git a/core/archipelago/src/content_server.rs b/core/archipelago/src/content_server.rs index 86904d00..f12d2f24 100644 --- a/core/archipelago/src/content_server.rs +++ b/core/archipelago/src/content_server.rs @@ -296,6 +296,24 @@ pub async fn serve_content( } } + let file_path = content_file_path(data_dir, item); + if !file_path.exists() { + // The catalog entry survived (it's a separate JSON file) but its + // backing file is gone — most likely lost in an unrelated data-dir + // reset (a shared filebrowser file, 2026-07-01: two catalog entries + // outlived a filebrowser reinstall that wiped the files themselves). + // Leaving the entry in place would keep advertising it as available + // to every peer forever, each hitting the exact same dead end this + // one just did. Prune it so it stops being offered. + warn!( + content_id = %id, + filename = %item.filename, + "content catalog entry's file is missing on disk — pruning the stale entry" + ); + prune_missing_content_entry(data_dir, id).await; + return Ok(ServeResult::NotFound); + } + // Check access control if !owner_session { match &item.access { @@ -307,8 +325,12 @@ pub async fn serve_content( // Each path only counts when the sharer accepts that method. let mut authorized = false; if let Some(token) = payment_token { - if (method_accepted(&item.access, "ecash") - || method_accepted(&item.access, "fedimint")) + let method = if token.trim().starts_with("cashu") { + "ecash" + } else { + "fedimint" + }; + if method_accepted(&item.access, method) && verify_payment_token(data_dir, token, *price_sats).await { authorized = true; @@ -336,24 +358,6 @@ pub async fn serve_content( } } - let file_path = content_file_path(data_dir, item); - if !file_path.exists() { - // The catalog entry survived (it's a separate JSON file) but its - // backing file is gone — most likely lost in an unrelated data-dir - // reset (a shared filebrowser file, 2026-07-01: two catalog entries - // outlived a filebrowser reinstall that wiped the files themselves). - // Leaving the entry in place would keep advertising it as available - // to every peer forever, each hitting the exact same dead end this - // one just did. Prune it so it stops being offered. - warn!( - content_id = %id, - filename = %item.filename, - "content catalog entry's file is missing on disk — pruning the stale entry" - ); - prune_missing_content_entry(data_dir, id).await; - return Ok(ServeResult::NotFound); - } - let metadata = fs::metadata(&file_path) .await .context("Failed to read file metadata")?; @@ -573,7 +577,7 @@ pub async fn serve_content_preview(data_dir: &Path, id: &str) -> Result bool { match crate::wallet::ecash::verify_and_receive_payment(data_dir, token, required_sats).await { diff --git a/core/archipelago/src/settings/bitcoin_storage.rs b/core/archipelago/src/settings/bitcoin_storage.rs new file mode 100644 index 00000000..17b1c4e5 --- /dev/null +++ b/core/archipelago/src/settings/bitcoin_storage.rs @@ -0,0 +1,51 @@ +//! Install-time pruning preference, shared by Bitcoin Core and Knots. +//! Missing preference preserves the existing disk-based automatic selection. +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +#[derive(Default, Serialize, Deserialize)] +pub struct BitcoinStorage { + pub prune: bool, +} + +pub async fn load(data_dir: &Path) -> Result { + match tokio::fs::read(data_dir.join("settings/bitcoin-storage.json")).await { + Ok(bytes) => serde_json::from_slice(&bytes).context("Invalid Bitcoin storage settings"), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BitcoinStorage::default()), + Err(e) => Err(e.into()), + } +} + +pub async fn save(data_dir: &Path, prune: bool) -> Result<()> { + let dir = data_dir.join("settings"); + tokio::fs::create_dir_all(&dir).await?; + let path = dir.join("bitcoin-storage.json"); + let temporary = dir.join("bitcoin-storage.json.tmp"); + tokio::fs::write(&temporary, serde_json::to_vec(&BitcoinStorage { prune })?).await?; + tokio::fs::rename(temporary, path).await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test] + async fn missing_setting_keeps_auto_and_explicit_pruning_survives_reload() { + let dir = tempfile::tempdir().unwrap(); + assert!(!load(dir.path()).await.unwrap().prune); + save(dir.path(), true).await.unwrap(); + assert!(load(dir.path()).await.unwrap().prune); + save(dir.path(), false).await.unwrap(); + assert!(!load(dir.path()).await.unwrap().prune); + } + #[tokio::test] + async fn corrupt_setting_is_not_silently_changed_to_archival() { + let dir = tempfile::tempdir().unwrap(); + save(dir.path(), true).await.unwrap(); + tokio::fs::write(dir.path().join("settings/bitcoin-storage.json"), "broken") + .await + .unwrap(); + assert!(load(dir.path()).await.is_err()); + } +} diff --git a/core/archipelago/src/settings/mod.rs b/core/archipelago/src/settings/mod.rs index db3bd5a5..8ffaf648 100644 --- a/core/archipelago/src/settings/mod.rs +++ b/core/archipelago/src/settings/mod.rs @@ -7,3 +7,5 @@ pub mod ai_permissions; pub mod session_policy; pub mod transport; + +pub mod bitcoin_storage; diff --git a/core/archipelago/src/wallet/ecash.rs b/core/archipelago/src/wallet/ecash.rs index e77647a8..ecc5c932 100644 --- a/core/archipelago/src/wallet/ecash.rs +++ b/core/archipelago/src/wallet/ecash.rs @@ -775,7 +775,9 @@ pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) -> let mut all_target: Vec = send_denoms.clone(); all_target.extend(&change_denoms); - let swap_result = client.swap(&selected_proofs, &all_target).await?; + let swap_result = client + .swap_at_least(&selected_proofs, &all_target, amount_sats) + .await?; // Mark original proofs as spent wallet.mark_spent(&indices); @@ -1192,7 +1194,11 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result { // Verify all mints in the token are accepted let accepted = load_accepted_mints(data_dir).await?; for mint_url in token.mint_urls() { - if !accepted.mints.iter().any(|m| m == mint_url) { + if !accepted + .mints + .iter() + .any(|m| m.trim_end_matches('/') == mint_url.trim_end_matches('/')) + { anyhow::bail!("Mint '{}' is not in accepted mints list", mint_url); } } @@ -1217,7 +1223,7 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result { received_total += amount; } Err(e) => { - warn!("Failed to swap proofs from mint {}: {:#}", entry.mint, e); + warn!("Failed to swap proofs from mint {}: {}", entry.mint, e); all_already_redeemed &= e.is::(); last_reason = Some(e.to_string()); // Continue with other mints if any @@ -1298,22 +1304,10 @@ pub async fn verify_and_receive_payment( token_str: &str, required_sats: u64, ) -> Result { - // Handle legacy tokens + let token_str = token_str.trim(); + // Synthetic legacy balances are not cryptographic proof of payment. if token_str.starts_with("cashuSend_") { - let amount = token_str - .split('_') - .nth(1) - .and_then(|s| s.parse::().ok()) - .unwrap_or(0); - if amount < required_sats { - anyhow::bail!( - "Insufficient payment: {} sats, need {} sats", - amount, - required_sats - ); - } - let received = receive_legacy_token(data_dir, token_str).await?; - return Ok(received); + anyhow::bail!("Legacy ecash cannot authorize a paid download"); } // Fedimint notes (#3): a buyer whose balance is in Fedimint pays with notes @@ -1336,52 +1330,45 @@ pub async fn verify_and_receive_payment( // Parse and validate the token (cashuA or cashuB) let token = CashuToken::deserialize(token_str)?; - let total = token.total_amount(); - + if token.unit.as_deref().unwrap_or("sat") != "sat" { + anyhow::bail!("Payment must be denominated in sats"); + } + // A sale must redeem atomically at one mint. Otherwise a later mint + // failure can consume earlier inputs without delivering the purchase. + let entry = match token.token.as_slice() { + [entry] => entry, + _ => anyhow::bail!("Use a single-mint token for this payment"), + }; + let total = entry + .proofs + .iter() + .try_fold(0u64, |sum, p| sum.checked_add(p.amount)) + .ok_or_else(|| anyhow::anyhow!("Payment amount overflow"))?; if total < required_sats { - anyhow::bail!( - "Insufficient payment: {} sats, need {} sats", - total, - required_sats - ); + anyhow::bail!("Insufficient payment: {total} sats, need {required_sats} sats"); } - - // Verify mints are accepted let accepted = load_accepted_mints(data_dir).await?; - for mint_url in token.mint_urls() { - if !accepted.mints.iter().any(|m| m == mint_url) { - anyhow::bail!("Mint '{}' not accepted", mint_url); - } + if !accepted + .mints + .iter() + .any(|m| m.trim_end_matches('/') == entry.mint.trim_end_matches('/')) + { + anyhow::bail!("Mint is not in the seller's accepted mints list"); } - // Swap proofs at mint (this verifies they're unspent and gives us fresh proofs) + let client = mint_client(data_dir, &entry.mint).await?; + let result = client + .swap_at_least( + &entry.proofs, + &amount_to_denominations(total), + required_sats, + ) + .await?; + let received_total = result.new_proofs.iter().map(|p| p.amount).sum(); + // Load after the network call, so an unrelated wallet update during the + // swap is not overwritten with a pre-swap snapshot. let mut wallet = load_wallet(data_dir).await?; - let mut received_total = 0u64; - - for entry in &token.token { - let client = mint_client(data_dir, &entry.mint).await?; - let entry_total: u64 = entry.proofs.iter().map(|p| p.amount).sum(); - let target_amounts = amount_to_denominations(entry_total); - - match client.swap(&entry.proofs, &target_amounts).await { - Ok(result) => { - let amount: u64 = result.new_proofs.iter().map(|p| p.amount).sum(); - wallet.add_proofs(&entry.mint, result.new_proofs); - received_total += amount; - } - Err(e) => { - warn!("Payment verification failed at mint {}: {}", entry.mint, e); - } - } - } - - if received_total < required_sats { - anyhow::bail!( - "Payment verification failed: only {} of {} sats verified", - received_total, - required_sats - ); - } + wallet.add_proofs(entry.mint.trim_end_matches('/'), result.new_proofs); wallet.record_tx( TransactionType::Receive, @@ -2465,3 +2452,7 @@ mod tests { assert_eq!(w.mint_url, "https://mint.minibits.cash/Bitcoin"); } } + +#[cfg(test)] +#[path = "payment_tests.rs"] +mod payment_tests; diff --git a/core/archipelago/src/wallet/mint_client.rs b/core/archipelago/src/wallet/mint_client.rs index 83950898..85ed629b 100644 --- a/core/archipelago/src/wallet/mint_client.rs +++ b/core/archipelago/src/wallet/mint_client.rs @@ -153,6 +153,20 @@ fn mint_error(op: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Erro cause.context(describe_mint_error_body(status, body)) } +fn fee_adjusted_targets(requested: &[u64], mut available: u64) -> Vec { + let mut outputs = Vec::new(); + for &amount in requested { + if available >= amount { + outputs.push(amount); + available -= amount; + } else { + outputs.extend(amount_to_denominations(available)); + break; + } + } + outputs +} + /// HTTP client for a single Cashu mint. pub struct MintClient { url: String, @@ -512,6 +526,21 @@ impl MintClient { /// Swap proofs for new proofs of different denominations. /// This is how we "receive" a token — swap it for fresh proofs that only we know. pub async fn swap(&self, inputs: &[Proof], target_amounts: &[u64]) -> Result { + self.swap_at_least(inputs, target_amounts, 0).await + } + + /// Refuse a payment whose mint fees would leave the seller underpaid, + /// before consuming any input proofs. + pub async fn swap_at_least( + &self, + inputs: &[Proof], + target_amounts: &[u64], + minimum: u64, + ) -> Result { + // V4 tokens carry short keyset IDs. Every swap path (including paid + // files and streams) must expand these, not only wallet imports. + let resolved = self.resolve_truncated_keyset_ids(inputs).await?; + let inputs = resolved.as_slice(); let keyset = self.get_active_sat_keyset().await?; // NUT-02: a mint may charge a per-input fee, and it rejects the swap @@ -519,16 +548,35 @@ impl MintClient { // should equal outputs less fee`). Applied here rather than at each // call site so send, receive and cross-mint swaps are all covered. // Fee-free mints (Minibits) compute 0 and are unaffected. - let inputs_total: u64 = inputs.iter().map(|p| p.amount).sum(); - let fee = match self.get_keysets().await { - Ok(ks) => super::cashu::swap_fee_for(inputs, &ks), - Err(e) => { - debug!("Could not read keyset fees ({e:#}) — assuming fee-free mint"); - 0 - } - }; + anyhow::ensure!(!inputs.is_empty(), "No input proofs to swap"); + let inputs_total = inputs + .iter() + .try_fold(0u64, |sum, p| sum.checked_add(p.amount)) + .context("Input amount overflow")?; + let keysets = self.get_keysets().await?; + let mut fee_ppk = 0u64; + for proof in inputs { + let input_keyset = keysets + .iter() + .find(|k| k.id == proof.id) + .context("The mint does not recognize an input keyset")?; + anyhow::ensure!( + input_keyset.unit == "sat", + "Input keyset is not denominated in sats" + ); + fee_ppk = fee_ppk + .checked_add(input_keyset.input_fee_ppk) + .context("Mint fee overflow")?; + } + let fee = fee_ppk.div_ceil(1000); let spendable = inputs_total.saturating_sub(fee); - let requested: u64 = target_amounts.iter().sum(); + if spendable < minimum { + anyhow::bail!("Payment would leave {spendable} sats after mint fees; need {minimum} sats. No proofs were redeemed."); + } + let requested = target_amounts + .iter() + .try_fold(0u64, |sum, amount| sum.checked_add(*amount)) + .context("Output amount overflow")?; let owned_targets: Vec; let target_amounts: &[u64] = if requested > spendable { if spendable == 0 { @@ -539,7 +587,10 @@ impl MintClient { debug!( "Reducing swap outputs {requested} -> {spendable} to cover a {fee} sat mint fee" ); - owned_targets = amount_to_denominations(spendable); + // Callers put payment outputs before change. Keep that prefix + // intact while fees reduce change; re-splitting the entire sum + // can omit a payment denomination after consuming the inputs. + owned_targets = fee_adjusted_targets(target_amounts, spendable); &owned_targets } else { target_amounts @@ -584,6 +635,9 @@ impl MintClient { let mut new_proofs = Vec::new(); for (sig, (secret, r, amount)) in signatures.iter().zip(blinding_data.iter()) { + if sig.amount != *amount || sig.id != keyset.id { + anyhow::bail!("Mint returned a swap signature for an unexpected amount or keyset"); + } let c_prime = sig.c_prime_as_pubkey()?; let mint_key = keyset.key_for_amount(*amount)?; let c = bdhke::unblind_signature(&c_prime, r, &mint_key)?; @@ -730,43 +784,35 @@ impl MintClient { /// Repair proofs whose keyset id is a truncated NUT-02 **v2** id. /// /// A v2 keyset id is 33 bytes (version byte `0x01` + 32-byte hash), but - /// wallets written against the original 8-byte format truncate it when - /// they build a token. The mint then reads the `0x01` version, expects 33 + /// compact V4 tokens carry an 8-byte short ID. The swap endpoint needs + /// the full ID restored from the mint's keyset list. The mint then reads the `0x01` version, expects 33 /// bytes, and rejects the swap — reported as /// `inputs[0].id: NUT02: ID length invalid` behind a bare 422 (seen with /// a Minibits-issued token, 2026-08-17). /// /// The id only names which keyset signed the proof, so restoring the full /// id the mint advertises is exactly what the sender meant. It is also - /// safe to attempt: an id that names the wrong keyset fails signature - /// verification at the mint and no coins move. Anything already valid, or - /// with no unambiguous match, is passed through untouched so the mint's - /// own error is what the operator sees. - async fn resolve_truncated_keyset_ids(&self, proofs: &[Proof]) -> Vec { + /// safe to attempt: the mint still verifies the proof signature. Unknown + /// or ambiguous short IDs are rejected before redemption. + async fn resolve_truncated_keyset_ids(&self, proofs: &[Proof]) -> Result> { let needs_repair = proofs.iter().any(|p| is_truncated_v2_keyset_id(&p.id)); if !needs_repair { - return proofs.to_vec(); + return Ok(proofs.to_vec()); } // The mint's own keyset list, in the reference implementation's shape // so its NUT-02 resolver can consume it directly. - let known = match self.get_cdk_keysets().await { - Ok(k) => k, - Err(e) => { - debug!("Could not list keysets to repair truncated keyset ids: {e:#}"); - return proofs.to_vec(); - } - }; + let known = self.get_cdk_keysets().await?; proofs .iter() .cloned() .map(|mut p| { - if let Some(full) = super::cashu::resolve_keyset_id(&p.id, &known) { - debug!("Expanded short keyset id {} to {} for swap", p.id, full); - p.id = full; + if is_truncated_v2_keyset_id(&p.id) { + p.id = super::cashu::resolve_keyset_id(&p.id, &known) + .context("The mint cannot resolve this short keyset ID unambiguously")?; } - p + Ok(p) }) .collect() } @@ -802,7 +848,7 @@ impl MintClient { let mut all_new_proofs = Vec::new(); for entry in &token.token { - if entry.mint != self.url { + if entry.mint.trim_end_matches('/') != self.url { debug!( "Skipping proofs from different mint {} (ours: {})", entry.mint, self.url @@ -813,8 +859,7 @@ impl MintClient { let total: u64 = entry.proofs.iter().map(|p| p.amount).sum(); let target_amounts = amount_to_denominations(total); - let proofs = self.resolve_truncated_keyset_ids(&entry.proofs).await; - let result = self.swap(&proofs, &target_amounts).await?; + let result = self.swap(&entry.proofs, &target_amounts).await?; all_new_proofs.extend(result.new_proofs); } diff --git a/core/archipelago/src/wallet/payment_tests.rs b/core/archipelago/src/wallet/payment_tests.rs new file mode 100644 index 00000000..9fb40266 --- /dev/null +++ b/core/archipelago/src/wallet/payment_tests.rs @@ -0,0 +1,428 @@ +//! Real HTTP/curve-signature regressions for paid Cashu redemption. +use super::*; +use crate::wallet::{bdhke, cashu::Proof}; +use bitcoin::secp256k1::{PublicKey, Scalar, Secp256k1, SecretKey}; +use hyper::{ + service::{make_service_fn, service_fn}, + Body, Request, Response, Server, +}; +use serde_json::{json, Value}; +use std::{ + convert::Infallible, + sync::{Arc, Mutex}, +}; + +const ACTIVE: &str = "0011223344556677"; +const V2: &str = "011111111111111111111111111111111111111111111111111111111111111111"; + +struct Mint { + url: String, + requests: Arc>>, + task: tokio::task::JoinHandle<()>, + failure: Arc, +} +impl Drop for Mint { + fn drop(&mut self) { + self.task.abort(); + } +} + +fn signing_key() -> SecretKey { + SecretKey::from_slice(&[7; 32]).unwrap() +} +fn signed_point(point: PublicKey) -> String { + point + .mul_tweak(&Secp256k1::new(), &Scalar::from(signing_key())) + .unwrap() + .to_string() +} +fn proof(id: &str, amount: u64) -> Proof { + let secret = format!("test-{id}-{amount}"); + Proof { + amount, + id: id.into(), + c: signed_point(bdhke::hash_to_curve(secret.as_bytes()).unwrap()), + secret, + } +} +impl Mint { + async fn start(fee: u64, failure: Option) -> Self { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let requests = Arc::new(Mutex::new(Vec::new())); + let seen = requests.clone(); + let failure = Arc::new(std::sync::atomic::AtomicU16::new(failure.unwrap_or(0))); + let rejection = failure.clone(); + let spent = Arc::new(Mutex::new(std::collections::HashSet::::new())); + let service = make_service_fn(move |_| { + let seen = seen.clone(); + let rejection = rejection.clone(); + let spent = spent.clone(); + async move { + Ok::<_, Infallible>(service_fn(move |req: Request| { + let seen = seen.clone(); + let rejection = rejection.clone(); + let spent = spent.clone(); + async move { + let mut status = 200; + let body = match req.uri().path() { + "/v1/keysets" => json!({"keysets":[ + {"id": ACTIVE,"unit":"sat","active":true,"input_fee_ppk":fee}, + {"id": V2,"unit":"sat","active":false,"input_fee_ppk":fee} + ]}), + "/v1/keys" => { + let public = + PublicKey::from_secret_key(&Secp256k1::new(), &signing_key()) + .to_string(); + let keys: serde_json::Map = (0..16) + .map(|i| ((1u64 << i).to_string(), json!(public))) + .collect(); + json!({"keysets":[{"id": ACTIVE,"unit":"sat","keys":keys}]}) + } + "/v1/swap" => { + let body: Value = serde_json::from_slice( + &hyper::body::to_bytes(req.into_body()).await.unwrap(), + ) + .unwrap(); + seen.lock().unwrap().push(body.clone()); + let inputs = body["inputs"].as_array().unwrap(); + let outputs = body["outputs"].as_array().unwrap(); + let code = rejection.load(std::sync::atomic::Ordering::SeqCst); + if code != 0 { + status = code; + json!({"detail":"mock mint rejection"}) + } else if inputs.iter().any(|p| p["id"] != V2 && p["id"] != ACTIVE) + { + status = 422; + json!({"detail":[{"msg":"NUT02: ID length invalid"}]}) + } else if inputs.iter().any(|p| { + spent + .lock() + .unwrap() + .contains(p["secret"].as_str().unwrap()) + }) { + status = 400; + json!({"code":11001,"detail":"Token Already Spent"}) + } else { + let total: u64 = + inputs.iter().map(|p| p["amount"].as_u64().unwrap()).sum(); + let out: u64 = + outputs.iter().map(|p| p["amount"].as_u64().unwrap()).sum(); + assert_eq!( + out, + total - (inputs.len() as u64 * fee).div_ceil(1000) + ); + for p in inputs { + spent + .lock() + .unwrap() + .insert(p["secret"].as_str().unwrap().into()); + } + json!({"signatures":outputs.iter().map(|o| json!({ + "amount":o["amount"],"id":ACTIVE, + "C_":signed_point(o["B_"].as_str().unwrap().parse().unwrap()) + })).collect::>()}) + } + } + _ => { + status = 404; + json!({}) + } + }; + Ok::<_, Infallible>( + Response::builder() + .status(status) + .header("Content-Type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + } + })) + } + }); + let server = Server::from_tcp(listener).unwrap().serve(service); + let task = tokio::spawn(async move { + server.await.unwrap(); + }); + Self { + url, + requests, + task, + failure, + } + } + async fn wallet(&self) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + save_accepted_mints( + dir.path(), + &AcceptedMints { + mints: vec![format!("{}/", self.url)], + }, + ) + .await + .unwrap(); + dir + } +} + +#[tokio::test] +async fn paid_v4_inactive_v2_keyset_is_expanded_and_cryptographic_proofs_saved() { + let mint = Mint::start(0, None).await; + let dir = mint.wallet().await; + let token = CashuToken::new(&mint.url, vec![proof(V2, 64), proof(V2, 32), proof(V2, 4)]) + .serialize_v4() + .unwrap(); + let decoded = CashuToken::deserialize(&token).unwrap(); + assert_eq!( + decoded.token[0].proofs[0].id.len(), + 16, + "reproduce the short V4 ID" + ); + assert_eq!( + verify_and_receive_payment(dir.path(), &token, 100) + .await + .unwrap(), + 100 + ); + let wallet = load_wallet(dir.path()).await.unwrap(); + assert_eq!(wallet.balance(), 100); + for p in wallet.proofs { + assert_eq!( + p.proof.c, + signed_point(bdhke::hash_to_curve(p.proof.secret.as_bytes()).unwrap()) + ); + } + assert!(mint.requests.lock().unwrap()[0]["inputs"] + .as_array() + .unwrap() + .iter() + .all(|p| p["id"] == V2)); + assert!(verify_and_receive_payment(dir.path(), &token, 100) + .await + .is_err()); + assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 100); +} + +#[tokio::test] +async fn paid_v3_full_v2_and_v1_ids_work() { + for id in [V2, ACTIVE] { + let mint = Mint::start(0, None).await; + let dir = mint.wallet().await; + let token = CashuToken::new(&mint.url, vec![proof(id, 128)]) + .serialize() + .unwrap(); + assert_eq!( + verify_and_receive_payment(dir.path(), &token, 100) + .await + .unwrap(), + 128 + ); + } +} + +#[tokio::test] +async fn fees_cannot_consume_underpayment_and_allowed_fees_credit_actual_value() { + let mint = Mint::start(1000, None).await; + let dir = mint.wallet().await; + let token = CashuToken::new(&mint.url, vec![proof(V2, 128)]) + .serialize_v4() + .unwrap(); + assert!(verify_and_receive_payment(dir.path(), &token, 128) + .await + .unwrap_err() + .to_string() + .contains("after mint fees")); + assert!(mint.requests.lock().unwrap().is_empty()); + assert_eq!( + verify_and_receive_payment(dir.path(), &token, 127) + .await + .unwrap(), + 127 + ); + assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 127); +} + +#[tokio::test] +async fn rejected_mint_response_does_not_credit_wallet() { + for status in [200, 400, 422, 500, 503] { + let mint = Mint::start(0, Some(status)).await; + let dir = mint.wallet().await; + let token = CashuToken::new(&mint.url, vec![proof(V2, 128)]) + .serialize_v4() + .unwrap(); + assert!(verify_and_receive_payment(dir.path(), &token, 100) + .await + .is_err()); + assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 0); + } +} + +#[tokio::test] +async fn invalid_untrusted_multimint_and_underpaid_tokens_never_reach_swap() { + let mint = Mint::start(0, None).await; + let dir = mint.wallet().await; + let token = CashuToken::new(&mint.url, vec![proof(V2, 128)]); + let mut invalid = vec![ + "cashuSend_500_abc_1700000000".into(), + "cashuBinvalid".into(), + ]; + let mut wrong_unit = token.clone(); + wrong_unit.unit = Some("usd".into()); + invalid.push(wrong_unit.serialize().unwrap()); + let mut multi = token.clone(); + multi.token.push(token.token[0].clone()); + invalid.push(multi.serialize().unwrap()); + let mut untrusted = token.clone(); + untrusted.token[0].mint = "http://127.0.0.1:1".into(); + invalid.push(untrusted.serialize().unwrap()); + for id in ["00ffffffffffffff", "01ffffffffffffff"] { + invalid.push( + CashuToken::new(&mint.url, vec![proof(id, 128)]) + .serialize() + .unwrap(), + ); + } + for value in invalid { + assert!(verify_and_receive_payment(dir.path(), &value, 100) + .await + .is_err()); + } + assert!( + verify_and_receive_payment(dir.path(), &token.serialize().unwrap(), 129) + .await + .is_err() + ); + assert!(mint.requests.lock().unwrap().is_empty()); + assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 0); +} + +#[tokio::test] +async fn buyer_token_rejected_by_seller_can_be_refunded_without_balance_loss() { + let mint = Mint::start(0, Some(422)).await; + let buyer = mint.wallet().await; + let seller = mint.wallet().await; + let mut wallet = load_wallet(buyer.path()).await.unwrap(); + wallet.mint_url = mint.url.clone(); + wallet.add_proofs(&mint.url, vec![proof(V2, 64), proof(V2, 32), proof(V2, 4)]); + save_wallet(buyer.path(), &wallet).await.unwrap(); + let token = send_token(buyer.path(), 100).await.unwrap(); + assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 0); + assert!(verify_and_receive_payment(seller.path(), &token, 100) + .await + .is_err()); + mint.failure.store(0, std::sync::atomic::Ordering::SeqCst); + assert_eq!(receive_token(buyer.path(), &token).await.unwrap(), 100); + assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 100); + assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 0); + assert!(receive_token(buyer.path(), &token).await.is_err()); + assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 100); +} + +#[tokio::test] +async fn unreachable_mint_does_not_credit_seller() { + let mint = Mint::start(0, None).await; + let dir = mint.wallet().await; + let token = CashuToken::new(&mint.url, vec![proof(V2, 128)]) + .serialize_v4() + .unwrap(); + mint.task.abort(); + tokio::task::yield_now().await; + assert!(verify_and_receive_payment(dir.path(), &token, 100) + .await + .is_err()); + assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 0); +} + +#[tokio::test] +async fn send_with_fees_preserves_payment_denominations_and_saves_change() { + // 128 inputs - 2 fee = 126. Splitting 126 as one sum omits 1, + // which is needed for a 65-sat payment, after consuming the inputs. + let mint = Mint::start(1000, None).await; + let buyer = mint.wallet().await; + let mut wallet = load_wallet(buyer.path()).await.unwrap(); + wallet.mint_url = mint.url.clone(); + let first = proof(V2, 64); + let mut second = first.clone(); + second.secret.push_str("-second"); + second.c = signed_point(bdhke::hash_to_curve(second.secret.as_bytes()).unwrap()); + wallet.add_proofs(&mint.url, vec![first, second]); + save_wallet(buyer.path(), &wallet).await.unwrap(); + let encoded = send_token(buyer.path(), 65).await.unwrap(); + assert_eq!( + CashuToken::deserialize(&encoded).unwrap().total_amount(), + 65 + ); + assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 61); +} + +#[tokio::test] +async fn paid_file_gate_delivers_bytes_only_after_payment_and_does_not_charge_missing_files() { + use crate::content_server::{ + self, AccessControl, Availability, ContentCatalog, ContentItem, ServeResult, + }; + for (exists, accepts_cashu, price) in [ + (true, true, 100), + (true, false, 100), + (false, true, 100), + (true, true, 129), + ] { + let mint = Mint::start(0, None).await; + let seller = mint.wallet().await; + let item = ContentItem { + id: "paid-test".into(), + filename: "test.txt".into(), + mime_type: "text/plain".into(), + size_bytes: 5, + description: String::new(), + added_at: String::new(), + availability: Availability::AllPeers, + access: AccessControl::Paid { + price_sats: price, + accepted: vec![if accepts_cashu { "ecash" } else { "fedimint" }.into()], + }, + }; + content_server::save_catalog(seller.path(), &ContentCatalog { items: vec![item] }) + .await + .unwrap(); + if exists { + tokio::fs::create_dir_all(seller.path().join("content/files")) + .await + .unwrap(); + tokio::fs::write(seller.path().join("content/files/test.txt"), b"hello") + .await + .unwrap(); + } + let token = CashuToken::new(&mint.url, vec![proof(V2, 128)]) + .serialize_v4() + .unwrap(); + let result = content_server::serve_content( + seller.path(), + "paid-test", + Some(&token), + None, + None, + None, + false, + ) + .await + .unwrap(); + if exists && accepts_cashu && price <= 128 { + match result { + ServeResult::Ok(bytes, mime) => { + assert_eq!(bytes, b"hello"); + assert_eq!(mime, "text/plain"); + } + _ => panic!("paid content was not delivered"), + } + assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 128); + } else { + assert!(matches!( + result, + ServeResult::NotFound | ServeResult::PaymentRequired(_) + )); + assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 0); + assert!(mint.requests.lock().unwrap().is_empty()); + } + } +} diff --git a/docker/lnd-ui/index.html b/docker/lnd-ui/index.html index c5cf8da5..2e1edf11 100644 --- a/docker/lnd-ui/index.html +++ b/docker/lnd-ui/index.html @@ -989,7 +989,7 @@ // ── State ─────────────────────────────────────────────────────── let unit = 'sats'; - let state = { info: null, channels: [], pending: null, peers: [], payments: [], invoices: [], txns: [], fees: null, graph: null }; + let state = { readiness: null, info: null, channels: [], pending: null, peers: [], payments: [], invoices: [], txns: [], fees: null, graph: null }; let peerSort = { col: 'peer', dir: 1 }; let activityFilter = 'all'; let logsLoaded = false; @@ -1142,9 +1142,19 @@ } async function refreshAll() { + if (state.refreshing) return; + state.refreshing = true; const icon = document.getElementById('refreshIcon'); if (icon) icon.classList.add('animate-spin-slow'); try { + state.readiness = await lndSafe('/archy-status', null); + if (state.readiness && state.readiness.state.startsWith('waiting_')) { + state.info = null; + state.onchainStale = true; + state.chanbalStale = true; + renderAll(); + return; + } const [info, channels, pending, peers, fees, graph, payments, invoices, txns] = await Promise.all([ lndSafe('/v1/getinfo', null), lndSafe('/v1/channels', { channels: [] }), @@ -1166,10 +1176,17 @@ state.invoices = (invoices && invoices.invoices) || []; state.txns = (txns && txns.transactions) || []; - // Balances are separate so one failing endpoint can't blank the rest. - state.onchain = await lndSafe('/v1/balance/blockchain', null); - state.chanbal = await lndSafe('/v1/balance/channels', null); + // Preserve known balances on outage; never decode an error as zero. + const [onchain, chanbal] = await Promise.all([ + lndSafe('/v1/balance/blockchain', null), + lndSafe('/v1/balance/channels', null), + ]); + state.onchainStale = !validBalance(onchain && (onchain.confirmed_balance ?? onchain.total_balance)); + state.chanbalStale = !validBalance(chanbal && (chanbal.local_balance?.sat ?? chanbal.balance)); + if (!state.onchainStale) state.onchain = onchain; + if (!state.chanbalStale) state.chanbal = chanbal; } finally { + state.refreshing = false; if (icon) icon.classList.remove('animate-spin-slow'); } renderAll(); @@ -1192,11 +1209,17 @@ const pill = document.getElementById('headerStatusPill'); const dot = document.getElementById('headerStatusDot'); - if (!g) { - setText('headerStatusText', 'Unreachable'); - pill.className = 'pill bad'; - dot.className = 'status-dot-sm bg-red'; - document.getElementById('syncCard').style.display = 'none'; + const waiting = state.readiness && state.readiness.state.startsWith('waiting_'); + if (!g || waiting) { + setText('headerStatusText', waiting ? state.readiness.message : 'Connecting to LND'); + pill.className = 'pill warn'; + dot.className = 'status-dot-sm bg-yellow'; + document.getElementById('syncCard').style.display = ''; + setText('syncSubtitle', waiting ? state.readiness.message + '. Lightning will become available automatically.' : 'Checking Lightning availability. Retrying automatically.'); + setText('syncBlockLabel', ''); + setText('syncPercent', ''); + document.getElementById('syncProgressBar').style.width = '0%'; + for (const id of ['syncChain', 'syncGraph', 'syncHeight', 'syncPeers']) setText(id, '—'); return; } @@ -1237,6 +1260,11 @@ } // ── Balances ──────────────────────────────────────────────────── + function validBalance(value) { + return (typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value))) + && Number.isSafeInteger(Number(value)) && Number(value) >= 0; + } + function renderBalances() { const onchainConfirmed = num(state.onchain && (state.onchain.confirmed_balance ?? state.onchain.total_balance)); const onchainUnconfirmed = num(state.onchain && state.onchain.unconfirmed_balance); @@ -1253,13 +1281,13 @@ const haveOnchain = !!state.onchain; const haveChan = !!cb; - setBalance('balTotal', haveOnchain || haveChan ? onchainConfirmed + lnLocal : null); - setText('balTotalSub', haveOnchain || haveChan ? 'on-chain + lightning' : 'waiting for LND'); + setBalance('balTotal', haveOnchain && haveChan ? onchainConfirmed + lnLocal : null); + setText('balTotalSub', state.onchainStale || state.chanbalStale ? 'balance unavailable · last known values' : haveOnchain && haveChan ? 'on-chain + lightning' : 'waiting for LND'); setBalance('balLightning', haveChan ? lnLocal : null); - setText('balLightningSub', !haveChan ? 'waiting for LND' + setText('balLightningSub', !haveChan ? 'waiting for LND' : state.chanbalStale ? 'last known balance' : lnPending > 0 ? fmtAmount(lnPending) + ' pending open' : 'spendable over channels'); setBalance('balOnchain', haveOnchain ? onchainConfirmed : null); - setText('balOnchainSub', !haveOnchain ? 'waiting for LND' + setText('balOnchainSub', !haveOnchain ? 'waiting for LND' : state.onchainStale ? 'last known balance' : onchainUnconfirmed > 0 ? fmtAmount(onchainUnconfirmed) + ' unconfirmed' : 'confirmed'); setText('liqLocal', fmtAmount(lnLocal)); diff --git a/docs/TODO.md b/docs/TODO.md index 1db93d91..fcf060a9 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -7,10 +7,27 @@ doc. See [`ROADMAP.md`](ROADMAP.md) for the curated, public-facing direction. - **OPEN: Framework LND startup / missing Receive address / false zero balance.** User requires investigation and a verified fix on the actual node before later - unrelated work. Access is pending; a manual LND restart is only a workaround. + unrelated work. Startup and native balances were verified on the actual node; + final display confirmation is pending. See the incident record for evidence. See [incident evidence and closure criteria](incident-framework-lnd-startup.md) and the repository `AGENTS.md` session-start instructions. +## Current repair and release tasks — 2026-09-29 + +Release is blocked until these pass; see [execution record](repair-release-20260929.md). + +- [ ] Fix Cashu paid-file redemption between dev and Shorty; test keyset IDs, + mint errors, fees, and refund reporting before live validation. +- [ ] Complete the remaining Framework incident verification and evidence. +- [ ] Replace the unavailable tx1138.com explorer default with mempool.space; + migrate the old default with fresh consent and preserve custom/local explorers. +- [ ] Offer pruning in the Bitcoin installation version modal, using the same + pruning settings as automatic pruning even on large disks. +- [ ] Explain Bitcoin warmup without raw RPC errors; gate LND unlock on Bitcoin + RPC readiness and show install/start/sync waiting states with automatic recovery. +- [ ] Test the completed changes on this development box, then publish a new + signed OTA and raw ISO release. Record any remaining verification gaps. + ## Dev & build process (priority) - Formalize the contributor workflow: releases, CI, maintainers, automated diff --git a/docs/incident-framework-lnd-startup.md b/docs/incident-framework-lnd-startup.md index 88d2d7d9..33d7c568 100644 --- a/docs/incident-framework-lnd-startup.md +++ b/docs/incident-framework-lnd-startup.md @@ -1,6 +1,6 @@ # Framework: LND startup, missing Receive address, false zero balance -**Status: OPEN — Framework startup and Cashu address verified live; source integration and final dashboard balance confirmation remain.** +**Status: OPEN — Framework startup, native balances, Cashu address and source integration verified; final rendered dashboard confirmation remains.** Reported: 2026-09-15. Source inspected: main at `3b9b74da` (v1.8.17-alpha publication). The Framework's installed version and exact incident time have not been verified. @@ -376,3 +376,18 @@ rename the address to disguise the problem. A Minibits server change could use Archy would instead require an Archy-hosted LNURL service/address and correct invoice metadata binding; rewriting the QR label or only proxying edited metadata is insufficient. No wallet/profile mutations were made during this investigation. + +### Source integration confirmed — 2026-09-29 + +`git merge-base --is-ancestor 4237fb5e HEAD` succeeds on main at +`540639d2`. The previously tested startup ordering, safe unlock, and unavailable +balance fixes are integrated and included in the intervening releases. The +earlier “source integration pending” notes above are historical, not current. +The user reports no further Framework incidents. Requested final confirmation +of rendered balances and Receive; do not mark closed without that response. + +A separate startup failure was observed on the development box today when Core +was installed against existing block data: Core made steady replay progress, +while LND exited on its short “bitcoind start timeout”. Candidate work defers +unlock until authenticated Bitcoin RPC answers, with dependency waiting states +in the LND UI. This is not evidence of a new failure on Framework. diff --git a/docs/repair-release-20260929.md b/docs/repair-release-20260929.md new file mode 100644 index 00000000..0c577a80 --- /dev/null +++ b/docs/repair-release-20260929.md @@ -0,0 +1,112 @@ +# Repair and release execution — 2026-09-29 + +**Status: IN PROGRESS. Do not publish an OTA or ISO until the release gates pass.** + +User requires all tasks completed and tested on the development box before the +next OTA and raw ISO. Passing unit tests alone does not establish live correctness. + +## Confirmed evidence + +- Dev-to-Shorty 100-sat Cashu file purchases failed twice. Both sellers' and + buyers' accepted mints match. Shorty's mint swap returned HTTP 422; both + attempted purchases were refunded 100 sats. The old message guessed a mint + mismatch without evidence. +- Wallet import repaired truncated V2 keyset IDs, while paid-content redemption + bypassed that repair. Central swap repair and protocol-level regression tests now pass. +- Core installation on dev reused existing chain data. At 17:42 UTC it was + advancing through block replay with no Core container restarts. At 17:49 UTC + it had connected to peers and started transaction-index synchronization. +- LND exited repeatedly with `bitcoind start timeout` while Core loaded. After + Core became available LND stayed running and reported waiting for backend sync. +- Framework source fix 4237fb5e is already an ancestor of main. Existing live + reboot/native balance evidence is in the incident document. Final display + confirmation remains pending. + +## Changes under validation + +- Cashu V4/V2 ID expansion at every swap; fee-aware underpayment rejection; + single-mint/sat-only/cryptographic paid tokens; no false mint-mismatch or + unconditional refund claims. Missing content checked before redemption. +- mempool.space default; migrate old tx1138 default with fresh consent, retain + local explorer priority and custom preferences. +- Core/Knots optional pruning on the version modal and app detail install path; + persist choice across runtime restarts; use identical 50,000 MiB automatic + pruning entrypoint behavior on large and small disks. +- Plain Bitcoin block-index startup message; defer LND wallet initialization or + unlock until Bitcoin RPC is usable; authenticated dependency status and LND UI + waiting states; no partial total displayed as a complete balance. + +## Validation and release gates + +- [x] Final backend regression suite passes (including mock mint HTTP and real + curve signatures, v1/full-v2/truncated-v2, fees, errors, duplicate redemption). +- [x] Initial explorer and pruning modal tests pass: 15 tests. +- [x] Both actual manifest entrypoints tested with isolated fake bitcoind across + 6 disk/choice combinations each. No existing chain pruned for this test. +- [x] Initial LND UI install/start/sync/recovery and invalid-balance tests pass. +- [x] Frontend production build and relevant existing wallet tests pass (34 + focused tests, including 12 Home failure/recovery checks). Final UI suite: 1,117 passed; production build passed. Updated gate rerun pending. +- [ ] Fault tests and final source review complete. +- [ ] Candidate deployed with rollback to dev and Shorty; hashes verified. +- [ ] Live paid-file purchase succeeds; failed purchase/refund behavior verified. +- [ ] Live waiting/recovery and UI state verified on dev. +- [ ] Framework final confirmation recorded. +- [ ] Release version/changelog, catalog/image implications, signing prepared. +- [ ] Signed OTA built, tested, published to git and ngit. +- [ ] Raw ISO built, boot-tested, signed and published; download command supplied. + +Tests must not wipe/recreate wallets, prune the operator's existing full chain, +or claim that arbitrary failures can never happen. Record material gaps before +release. Signing keys remain with the user; prepare concrete artifacts first. + +### Further startup findings + +Live dev `/v1/state` returned `RPC_ACTIVE` while `/v1/getinfo` timed out during +Bitcoin initial sync. Candidate startup now recognizes the already-unlocked +state instead of repeating unlock attempts for ten minutes. The health watchdog +also now excludes Bitcoin initial sync, warmup, unavailable/stale status and +LND height progress from its restart criteria. A later observed `podman restart` +was externally initiated; its precise caller has not yet been established, so +the watchdog defect is a source finding rather than a confirmed attribution. + +Framework SSH rejected the previously provided login on 2026-09-29. No password +was saved and no wallet changes were attempted. The human display-confirmation +question remains pending. Do not repeat a Framework reboot to reconfirm old work. + +LND UI waiting-state, stale-balance, partial-failure/recovery and prompt-render +tests pass (4 Node tests). Waiting states avoid calls to LND endpoints that block +until sync, and prevent overlapping refreshes. + +### Final source validation + +The final backend suite passed: 1,548 passed, zero failed, four existing ignored +live/hardware tests. Includes saved pruning preference, rejecting an old catalog +that cannot honor explicit pruning, and all nine paid-Cashu protocol tests. +Unsigned candidate catalog passes strict drift and fleet registry trust checks. +The release gate caught a missing What's New entry; generated it from the curated +changelog and reran the frontend gate/build. No public release has been changed. + +At 18:23 UTC dev Bitcoin exited with status 137 and restarted; current container +is not marked OOM-killed and no kernel/oomd record identified the cause. Bitcoin +is replaying blocks again (height 482071 at 18:31 UTC). Installed old LND continues +to time out while Bitcoin RPC warms up. Candidate is not deployed yet; verify its +readiness deferral live before declaring this fixed. Do not attribute the Bitcoin +exit to a specific actor without evidence. + +### Doctor restart cause established and repaired + +Full system journal identifies container-doctor at 18:23:21 UTC issuing raw +`podman restart bitcoin-core` for an allegedly missing 8333 listener. The same +script restarted LND at 17:57:48 and 18:23:35 UTC. The port was actually listening. +Reproduced the original `ss | awk | grep -q` pipeline returning `0 141 0`: grep +exits after its match, awk gets SIGPIPE, and pipefail falsely reports no listener. +The raw restart also enforces a short stop timeout and races Quadlet cleanup. + +The repaired check consumes the entire socket snapshot, distinguishes inspection +failure from a missing port, and leaves containers running when inspection fails. +Necessary restarts use their managed systemd units and shutdown timeouts; unmanaged +Bitcoin/LND fallback receives 600/330-second grace respectively. Regression uses +20,000 socket rows plus mocked service/container commands and passes. Thirty +read-only checks of the actual Bitcoin listener pass. Script deployed to dev and +Shorty with root-only rollback copies. OTA runtime payload includes scripts/. +This evidence supersedes the earlier unknown-caller/unknown-exit attribution. diff --git a/neode-ui/package.json b/neode-ui/package.json index 86ea4dc1..4747ca6f 100644 --- a/neode-ui/package.json +++ b/neode-ui/package.json @@ -1,7 +1,7 @@ { "name": "neode-ui", "private": true, - "version": "1.8.19-alpha", + "version": "1.8.20-alpha", "type": "module", "scripts": { "start": "./start-dev.sh", diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts index 67753d0e..454bc5c6 100644 --- a/neode-ui/src/api/rpc-client.ts +++ b/neode-ui/src/api/rpc-client.ts @@ -42,6 +42,7 @@ export interface PackageVersionsResponse { pinnedVersion: string | null autoUpdate: boolean versions: CatalogVersionInfo[] + bitcoinPrune?: boolean | null } export interface AppGatePortStatus { diff --git a/neode-ui/src/components/BitcoinPruningChoice.vue b/neode-ui/src/components/BitcoinPruningChoice.vue new file mode 100644 index 00000000..da54fa9f --- /dev/null +++ b/neode-ui/src/components/BitcoinPruningChoice.vue @@ -0,0 +1,21 @@ + + diff --git a/neode-ui/src/components/ExternalExplorerModal.vue b/neode-ui/src/components/ExternalExplorerModal.vue index 59af1d7c..f3d5d5ee 100644 --- a/neode-ui/src/components/ExternalExplorerModal.vue +++ b/neode-ui/src/components/ExternalExplorerModal.vue @@ -31,7 +31,7 @@ class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm font-mono focus:outline-none focus:border-orange-400/60" />

- Defaults to tx1138.com. Any Mempool-compatible instance works — you can change this + Defaults to mempool.space. Any Mempool-compatible instance works — you can change this any time in Settings → System.

diff --git a/neode-ui/src/components/InstallVersionModal.vue b/neode-ui/src/components/InstallVersionModal.vue index 99b6a9e9..603bb108 100644 --- a/neode-ui/src/components/InstallVersionModal.vue +++ b/neode-ui/src/components/InstallVersionModal.vue @@ -35,6 +35,8 @@

{{ t('marketplace.installModalHint') }}

+ +