diff --git a/AGENTS.md b/AGENTS.md index 6b75350c..46fbf04c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,3 +21,11 @@ While its status is OPEN: This priority comes from the user's explicit instruction on 2026-09-15. It remains in effect across sessions until the documented acceptance criteria are met or the user explicitly changes it. + +## Unit tests on a live node + +Run backend unit tests through `scripts/test-backend-isolated.sh`. Do not run +unrestricted `cargo test` on a node with installed apps: older mocked-runtime +tests still reached real service commands. The runner isolates wallet data, +service buses, container storage, networking, and process IDs. Compilation with +`cargo test --no-run` is safe. Keep separately authorized live checks explicit. diff --git a/CHANGELOG.md b/CHANGELOG.md index e8c5d070..72057cc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ ## Unreleased +## v1.8.21-alpha (2026-09-30) + +- Fixed Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts. +- Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting. +- Prevented unnecessary Lightning restarts when Bitcoin has stayed running; dependency restarts now require an observed Bitcoin container change. +- Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20. + +## 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. +- Improved saving paid files into Files and reopening purchases without paying again. +- 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..168c15d5 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.21-alpha" dependencies = [ "anyhow", "archipelago-container", diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index d920c578..87199f6a 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.21-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 d4b2b2da..91c0877b 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,16 +32,62 @@ 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() + } } } +/// Keep first purchases and cached repeats compatible with both existing clients. +fn paid_content_response(bytes: &[u8], mime: &str, paid_sats: u64) -> serde_json::Value { + use base64::Engine; + let data = base64::engine::general_purpose::STANDARD.encode(bytes); + serde_json::json!({ + "data": data, "data_base64": data, + "size": bytes.len(), "size_bytes": bytes.len(), + "mime_type": mime, "paid_sats": paid_sats, "owned": true, + }) +} + +/// File purchases through an atomic no-clobber write in Files' own namespace. +async fn file_purchase_in_files( + data_dir: &std::path::Path, + filename: &str, + mime: &str, + bytes: &[u8], +) -> Result { + let folder = if mime.starts_with("image/") || mime.starts_with("video/") { + "Photos" + } else if mime.starts_with("audio/") { + "Music" + } else { + "Documents" + }; + let root = data_dir.join("filebrowser"); + anyhow::ensure!( + tokio::fs::metadata(&root).await?.is_dir(), + "Files storage is unavailable" + ); + let name = std::path::Path::new(filename) + .file_name() + .and_then(|n| n.to_str()) + .filter(|n| !n.is_empty()) + .unwrap_or("download"); + let path = + crate::container::filebrowser::save_new_file(&root.join(folder), name, bytes).await?; + Ok(format!( + "{folder}/{}", + path.file_name() + .and_then(|n| n.to_str()) + .context("Invalid Files name")? + )) +} + impl RpcHandler { /// List content I'm sharing. pub(super) async fn handle_content_list_mine(&self) -> Result { @@ -463,17 +509,10 @@ impl RpcHandler { crate::content_owned::read_owned(&self.config.data_dir, &o.onion, &o.content_id) .await { - use base64::Engine; - return Ok(serde_json::json!({ - "owned": true, - "already_owned": true, - "filename": o.filename, - "mime_type": mime, - "size_bytes": bytes.len(), - "paid_sats": 0, - "data_base64": - base64::engine::general_purpose::STANDARD.encode(&bytes), - })); + let mut result = paid_content_response(&bytes, &mime, 0); + result["already_owned"] = serde_json::json!(true); + result["filename"] = serde_json::json!(o.filename); + return Ok(result); } // Cache record exists but bytes are gone — fall through and // repurchase rather than stranding the user. @@ -547,29 +586,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 +620,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 +638,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}") })); } @@ -658,47 +687,21 @@ impl RpcHandler { tracing::warn!("paid download: failed to cache purchased content (non-fatal): {e:#}"); } - // Auto-file the purchase into the user's Files area (2026-07-22): - // Photos for images/video, Music for audio, Documents otherwise — - // same buckets the Cloud view uses. The in-app viewer still plays - // from the purchase cache; this makes the file ALSO show up where - // files live, on every device, without relying on a browser - // download. Best-effort: never fail a paid download over it. - { - let folder = if mime_type.starts_with("image/") || mime_type.starts_with("video/") { - "Photos" - } else if mime_type.starts_with("audio/") { - "Music" - } else { - "Documents" - }; - let base = std::path::Path::new(&filename) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("download") - .to_string(); - let dir = self.config.data_dir.join("filebrowser").join(folder); - match crate::container::filebrowser::save_new_file(&dir, &base, &bytes).await { - Ok(path) => tracing::info!("paid download: filed into {}", path.display()), - Err(e) => tracing::warn!( - "paid download: filing into {} failed (non-fatal): {e:#}", - dir.display() - ), - } + // The durable purchased-content cache above is primary. A Files copy + // remains optional: a stopped FileBrowser must not undo a paid download. + let filed = + file_purchase_in_files(&self.config.data_dir, &filename, &mime_type, &bytes).await; + match filed { + Ok(path) => tracing::info!("paid download: filed into Files/{path}"), + Err(error) => tracing::warn!( + "paid download: optional Files copy failed; purchase cache retained: {error}" + ), } - use base64::Engine; - let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); - tracing::info!("paid download: received {} bytes from {onion} (paid {price_sats} sats via {used_backend})", bytes.len()); - Ok(serde_json::json!({ - "data": encoded, - "size": bytes.len(), - "paid_sats": price_sats, - "ecash_backend": used_backend, - "mime_type": mime_type, - "owned": true, - })) + let mut result = paid_content_response(&bytes, &mime_type, price_sats); + result["ecash_backend"] = serde_json::json!(used_backend); + Ok(result) } /// Buyer side (#46): ask the selling node to mint a Lightning invoice for a @@ -1371,3 +1374,7 @@ impl RpcHandler { } } } + +#[cfg(test)] +#[path = "content_tests.rs"] +mod tests; diff --git a/core/archipelago/src/api/rpc/content_tests.rs b/core/archipelago/src/api/rpc/content_tests.rs new file mode 100644 index 00000000..25491d13 --- /dev/null +++ b/core/archipelago/src/api/rpc/content_tests.rs @@ -0,0 +1,56 @@ +use super::*; + +#[test] +fn first_and_cached_paid_downloads_have_the_same_client_payload_contract() { + use base64::Engine; + for paid in [0, 1] { + let response = paid_content_response(&[0, 255, 123], "application/octet-stream", paid); + assert_eq!(response["data"], response["data_base64"]); + assert_eq!( + base64::engine::general_purpose::STANDARD + .decode(response["data"].as_str().unwrap()) + .unwrap(), + [0, 255, 123] + ); + assert_eq!(response["size"], 3); + assert_eq!(response["size_bytes"], 3); + assert_eq!(response["paid_sats"], paid); + assert_eq!(response["owned"], true); + } +} + +#[tokio::test] +async fn files_copy_routes_media_and_sanitizes_the_filename() { + let dir = tempfile::tempdir().unwrap(); + tokio::fs::create_dir(dir.path().join("filebrowser")) + .await + .unwrap(); + for (mime, folder) in [ + ("image/png", "Photos"), + ("video/mp4", "Photos"), + ("audio/mpeg", "Music"), + ("text/plain", "Documents"), + ] { + let relative = file_purchase_in_files(dir.path(), "../name #?.bin", mime, b"paid") + .await + .unwrap(); + assert!(relative.starts_with(&format!("{folder}/name #?"))); + assert_eq!( + tokio::fs::read(dir.path().join("filebrowser").join(relative)) + .await + .unwrap(), + b"paid" + ); + } +} + +#[tokio::test] +async fn unavailable_files_storage_is_reported_without_creating_a_fake_installation() { + let dir = tempfile::tempdir().unwrap(); + assert!( + file_purchase_in_files(dir.path(), "name", "text/plain", b"bytes") + .await + .is_err() + ); + assert!(!dir.path().join("filebrowser").exists()); +} 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/companion.rs b/core/archipelago/src/container/companion.rs index 9752454f..42f1b8da 100644 --- a/core/archipelago/src/container/companion.rs +++ b/core/archipelago/src/container/companion.rs @@ -313,7 +313,7 @@ async fn image_id(image_ref: &str) -> Option { /// should reference (`localhost/:latest` for build, registry /// URL for pull). async fn ensure_image_present(spec: &CompanionSpec) -> Result { - let local_image = format!("localhost/{}:latest", spec.image_base); + let mut local_image = format!("localhost/{}:latest", spec.image_base); let local_image_compat = format!("localhost/{}:local", spec.image_base); let registry_image = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base); @@ -322,11 +322,13 @@ async fn ensure_image_present(spec: &CompanionSpec) -> Result { for dir in spec.build_dir_candidates { let dockerfile = PathBuf::from(dir).join("Dockerfile"); if fs::try_exists(&dockerfile).await.unwrap_or(false) { - // `:local` is a deliberate manual override — never auto-rebuild it. + // Older installers and self-update create :local themselves. It + // must receive source updates too; treating it as a permanent + // manual override silently kept the old LND UI after an OTA. if image_exists(&local_image_compat).await { - return Ok(local_image_compat); + local_image = local_image_compat.clone(); } - // Reuse the auto-built `:latest` only when the build context has NOT + // Reuse either local tag only when the build context has NOT // changed since it was built. Without this staleness check an // already-present image is reused forever, so edits to the baked-in // context (Dockerfile, nginx.conf, …) never reach the node — this is @@ -849,20 +851,43 @@ async fn needs_repair(spec: &CompanionSpec) -> Result { if !matches_known_shape { return Ok(true); } - if on_disk.contains(&local_image) && !on_disk.contains(&local_image_compat) { + if let Some(image) = managed_local_image(spec, &on_disk) { for dir in spec.build_dir_candidates { let dockerfile = PathBuf::from(dir).join("Dockerfile"); if fs::try_exists(&dockerfile).await.unwrap_or(false) { // Conservative on any timeout/error inside: reuse the cache. - return Ok(context_is_newer_than_image(dir, &local_image).await); + return Ok(context_is_newer_than_image(dir, &image).await); } } } Ok(false) } +fn managed_local_image(spec: &CompanionSpec, unit: &str) -> Option { + ["latest", "local"] + .iter() + .map(|tag| format!("localhost/{}:{tag}", spec.image_base)) + .find(|image| build_unit(spec, image).render() == unit) +} + #[cfg(test)] mod tests { + #[test] + fn legacy_installer_local_tag_is_checked_for_source_updates_like_latest() { + for spec in ALL_COMPANIONS.iter().flat_map(|group| group.iter()) { + for tag in ["local", "latest"] { + let image = format!("localhost/{}:{tag}", spec.image_base); + let unit = build_unit(spec, &image).render(); + assert_eq!(managed_local_image(spec, &unit), Some(image)); + } + let registry = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base); + assert_eq!( + managed_local_image(spec, &build_unit(spec, ®istry).render()), + None + ); + } + } + use super::*; fn names(specs: &[&'static CompanionSpec]) -> Vec<&'static str> { diff --git a/core/archipelago/src/container/filebrowser.rs b/core/archipelago/src/container/filebrowser.rs index 18845c82..1e85fa11 100644 --- a/core/archipelago/src/container/filebrowser.rs +++ b/core/archipelago/src/container/filebrowser.rs @@ -117,20 +117,24 @@ fn shell_quote(s: &str) -> String { s.replace('\'', "'\\''") } -/// Save `bytes` into FileBrowser's storage as a new file in `dir`, named -/// `name` or, if that's taken, `name (2)`, `name (3)`… Never overwrites. -/// Returns the path written. -/// -/// FileBrowser's folders belong to its rootless container range (host uid -/// 100000, mode 755), so this service — host uid 1000, outside that range — -/// can read them but not write into them, and filing a purchase into Files -/// failed with EACCES (2026-09-29). When a direct write is refused, the file -/// is written through `podman unshare`, where that range is ours, and given -/// the folder's owner so FileBrowser manages it like its own uploads. +/// Save a complete purchase without overwriting any existing directory entry. +/// Both host and rootless-namespace paths publish with a no-clobber hard link. pub async fn save_new_file(dir: &Path, name: &str, bytes: &[u8]) -> Result { save_new_file_with(dir, name, bytes, write_via_userns).await } +fn validate_filename(name: &str) -> Result<()> { + anyhow::ensure!( + !name.is_empty() + && name != "." + && name != ".." + && !name.contains(['/', '\\', '\0']) + && name.len() <= 255, + "Invalid purchased filename" + ); + Ok(()) +} + async fn save_new_file_with( dir: &Path, name: &str, @@ -138,100 +142,170 @@ async fn save_new_file_with( fallback: F, ) -> Result where - F: FnOnce(PathBuf, Vec) -> Fut, - Fut: std::future::Future>, + F: FnOnce(PathBuf, String, Vec) -> Fut, + Fut: std::future::Future>, { - let target = unused_name(dir, name); - match write_direct(dir, &target, bytes).await { - Ok(()) => Ok(target), - Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { - fallback(target.clone(), bytes.to_vec()) + validate_filename(name)?; + // Never follow a user-created destination directory symlink. + match fs::symlink_metadata(dir).await { + Ok(meta) => anyhow::ensure!(meta.is_dir(), "Files destination is not a directory"), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + save_after_direct_result( + write_direct(dir, name, bytes).await, + dir, + name, + bytes, + fallback, + ) + .await +} + +async fn save_after_direct_result( + result: std::io::Result, + dir: &Path, + name: &str, + bytes: &[u8], + fallback: F, +) -> Result +where + F: FnOnce(PathBuf, String, Vec) -> Fut, + Fut: std::future::Future>, +{ + match result { + Ok(path) => Ok(path), + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { + fallback(dir.to_owned(), name.to_owned(), bytes.to_vec()) .await - .with_context(|| format!("writing {} via podman unshare", target.display()))?; - Ok(target) + .context("Saving purchase in Files user namespace") } - Err(e) => Err(e).with_context(|| format!("writing {}", target.display())), + Err(error) => Err(error).context("Saving purchase in Files"), } } -/// `dir/name`, or the first free `dir/stem (n).ext` from n = 2. -fn unused_name(dir: &Path, name: &str) -> PathBuf { - let mut target = dir.join(name); - let (stem, ext) = match name.rsplit_once('.') { - Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")), - _ => (name.to_string(), String::new()), - }; - let mut n = 2; - while target.exists() { - target = dir.join(format!("{stem} ({n}){ext}")); - n += 1; +fn numbered_name(name: &str, attempt: usize) -> String { + if attempt == 1 { + return name.to_owned(); + } + match name.rsplit_once('.') { + Some((stem, extension)) if !stem.is_empty() => format!("{stem} ({attempt}).{extension}"), + _ => format!("{name} ({attempt})"), } - target } -async fn write_direct(dir: &Path, target: &Path, bytes: &[u8]) -> std::io::Result<()> { +struct PendingFile(PathBuf); +impl Drop for PendingFile { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } +} + +async fn write_direct(dir: &Path, name: &str, bytes: &[u8]) -> std::io::Result { + use std::os::unix::fs::PermissionsExt; use tokio::io::AsyncWriteExt; fs::create_dir_all(dir).await?; - let mut f = fs::OpenOptions::new() + let temp_path = dir.join(format!(".archy-saving-{}", uuid::Uuid::new_v4())); + let mut file = fs::OpenOptions::new() .write(true) .create_new(true) - .open(target) + .mode(0o600) + .open(&temp_path) .await?; - let written = async { - f.write_all(bytes).await?; - f.flush().await + let temp = PendingFile(temp_path); + file.write_all(bytes).await?; + file.set_permissions(std::fs::Permissions::from_mode(0o644)) + .await?; + file.sync_all().await?; + for attempt in 1..=100 { + let target = dir.join(numbered_name(name, attempt)); + match fs::hard_link(&temp.0, &target).await { + Ok(()) => return Ok(target), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } } - .await; - if written.is_err() { - let _ = fs::remove_file(target).await; - } - written + Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "Too many existing copies; purchase cache retained", + )) } -/// Write `bytes` (piped on stdin) to `target` from inside the rootless user -/// namespace. It goes to a temp file first and is hard-linked into place, so -/// FileBrowser never sees a partial file and an existing file is never -/// replaced (`ln` refuses an existing name). -async fn write_via_userns(target: PathBuf, bytes: Vec) -> Result<()> { - use tokio::io::AsyncWriteExt; - const SCRIPT: &str = r#"set -eu -dst=$1 -dir=$(dirname -- "$dst") +// Positional arguments carry all user-controlled text. mktemp prevents temp-name +// collisions; ln -T refuses files, symlinks and directories, including races. +const WRITE_VIA_USERNS: &str = r#"set -eu +dir=$1 +name=$2 +expected=$3 +[ ! -L "$dir" ] || exit 1 if [ ! -d "$dir" ]; then - mkdir -- "$dir" + mkdir -p -- "$dir" chown --reference="$(dirname -- "$dir")" -- "$dir" fi -tmp="$dir/.archy-saving.$$" -trap 'rm -f -- "$tmp"' EXIT +tmp=$(mktemp "$dir/.archy-saving.XXXXXXXXXX") +trap 'rm -f -- "$tmp"' EXIT HUP INT TERM cat > "$tmp" +[ "$(wc -c < "$tmp")" -eq "$expected" ] || exit 1 chown --reference="$dir" -- "$tmp" chmod 0644 -- "$tmp" -ln -- "$tmp" "$dst" +sync -f -- "$tmp" +stem=$name +ext= +case "$name" in + *.*) prefix=${name%.*}; if [ -n "$prefix" ]; then stem=$prefix; ext=.${name##*.}; fi ;; +esac +n=1 +while [ "$n" -le 100 ]; do + candidate=$name + if [ "$n" -gt 1 ]; then candidate="$stem ($n)$ext"; fi + dst="$dir/$candidate" + if ln -T -- "$tmp" "$dst" 2>/dev/null; then + printf '%s' "$candidate" + exit 0 + fi + # A conflict may be a dangling symlink; never follow it or overwrite it. + if [ ! -e "$dst" ] && [ ! -L "$dst" ]; then exit 1; fi + n=$((n + 1)) +done +exit 1 "#; + +async fn write_via_userns(dir: PathBuf, name: String, bytes: Vec) -> Result { + use tokio::io::AsyncWriteExt; let mut child = tokio::process::Command::new("podman") - .args(["unshare", "sh", "-c", SCRIPT, "sh"]) - .arg(&target) + .args(["unshare", "sh", "-c", WRITE_VIA_USERNS, "sh"]) + .arg(&dir) + .arg(&name) + .arg(bytes.len().to_string()) + .kill_on_drop(true) .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() - .context("Failed to run podman unshare")?; - let mut stdin = child.stdin.take().context("podman unshare stdin")?; - let fed = stdin.write_all(&bytes).await; - drop(stdin); - let out = child - .wait_with_output() - .await - .context("Failed to wait for podman unshare")?; - if !out.status.success() { - anyhow::bail!( - "podman unshare exited with {}: {}", - out.status, - String::from_utf8_lossy(&out.stderr).trim() + .context("Starting Files namespace writer")?; + let mut stdin = child.stdin.take().context("Files writer stdin missing")?; + let operation = async { + let fed = stdin.write_all(&bytes).await; + drop(stdin); + let output = child.wait_with_output().await?; + anyhow::ensure!( + output.status.success(), + "Files namespace writer failed: {}", + output.status ); - } - fed.context("Failed to pipe the file to podman unshare")?; - Ok(()) + fed.context("Sending purchase bytes to Files")?; + let chosen = + String::from_utf8(output.stdout).context("Files writer returned an invalid name")?; + validate_filename(&chosen)?; + anyhow::ensure!( + (1..=100).any(|n| numbered_name(&name, n) == chosen), + "Files writer returned an unexpected name" + ); + Ok(dir.join(chosen)) + }; + tokio::time::timeout(std::time::Duration::from_secs(120), operation) + .await + .context("Files namespace writer timed out")? } #[cfg(test)] @@ -268,95 +342,232 @@ mod tests { let second = ensure_config(&paths).await.unwrap(); assert_eq!(second, EnsureOutcome::Unchanged); } +} - #[test] - fn unused_name_numbers_duplicates_and_keeps_the_extension() { - let dir = tempfile::tempdir().unwrap(); - let d = dir.path(); - assert_eq!(unused_name(d, "song.mp3"), d.join("song.mp3")); - std::fs::write(d.join("song.mp3"), b"").unwrap(); - assert_eq!(unused_name(d, "song.mp3"), d.join("song (2).mp3")); - std::fs::write(d.join("song (2).mp3"), b"").unwrap(); - assert_eq!(unused_name(d, "song.mp3"), d.join("song (3).mp3")); - std::fs::write(d.join("README"), b"").unwrap(); - assert_eq!(unused_name(d, "README"), d.join("README (2)")); - std::fs::write(d.join(".hidden"), b"").unwrap(); - assert_eq!(unused_name(d, ".hidden"), d.join(".hidden (2)")); +#[cfg(test)] +mod purchase_write_tests { + use super::*; + use std::{ + collections::HashSet, + os::unix::fs::{symlink, PermissionsExt}, + }; + + fn no_temps(dir: &Path) { + assert!(std::fs::read_dir(dir).unwrap().all(|e| !e + .unwrap() + .file_name() + .to_string_lossy() + .starts_with(".archy-saving"))); } #[tokio::test] - async fn save_new_file_writes_directly_into_a_writable_folder() { + async fn direct_write_uses_complete_bytes_and_preserves_originals() { let dir = tempfile::tempdir().unwrap(); - let music = dir.path().join("Music"); - let path = save_new_file_with(&music, "a.mp3", b"abc", |_, _| async { - anyhow::bail!("fallback must not run") - }) - .await - .unwrap(); - assert_eq!(path, music.join("a.mp3")); - assert_eq!(std::fs::read(&path).unwrap(), b"abc"); - } - - #[tokio::test] - async fn save_new_file_never_overwrites_an_existing_file() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("a.mp3"), b"original").unwrap(); - let path = save_new_file_with(dir.path(), "a.mp3", b"new", |_, _| async { - anyhow::bail!("fallback must not run") - }) - .await - .unwrap(); - assert_eq!(path, dir.path().join("a (2).mp3")); + fs::write(dir.path().join("song.mp3"), b"original") + .await + .unwrap(); + let target = save_new_file(dir.path(), "song.mp3", b"new").await.unwrap(); + assert_eq!(target.file_name().unwrap(), "song (2).mp3"); + assert_eq!(fs::read(target).await.unwrap(), b"new"); assert_eq!( - std::fs::read(dir.path().join("a.mp3")).unwrap(), + fs::read(dir.path().join("song.mp3")).await.unwrap(), b"original" ); + no_temps(dir.path()); } - /// Regression (2026-09-29): filing a purchase into a FileBrowser folder - /// owned by the container's uid range failed with EACCES. A refused - /// write must go through the user-namespace fallback, with the same - /// target and bytes. #[tokio::test] - async fn a_refused_write_goes_through_the_userns_fallback() { - use std::os::unix::fs::PermissionsExt; + async fn simultaneous_saves_publish_unique_complete_files() { let dir = tempfile::tempdir().unwrap(); - let music = dir.path().join("Music"); - std::fs::create_dir(&music).unwrap(); - std::fs::set_permissions(&music, std::fs::Permissions::from_mode(0o555)).unwrap(); - if std::fs::File::create(music.join("probe")).is_ok() { - return; // running as root: mode bits don't refuse the write + let mut tasks = Vec::new(); + for n in 0..24u8 { + let dir = dir.path().to_owned(); + tasks.push(tokio::spawn(async move { + let bytes = vec![n; 32768]; + let path = save_new_file(&dir, "same.bin", &bytes).await.unwrap(); + assert_eq!(fs::read(&path).await.unwrap(), bytes); + path + })); } + let mut paths = HashSet::new(); + for task in tasks { + assert!(paths.insert(task.await.unwrap())); + } + assert_eq!(paths.len(), 24); + no_temps(dir.path()); + } - let seen = std::sync::Mutex::new(None); - let path = save_new_file_with(&music, "a.mp3", b"abc", |target, bytes| { - *seen.lock().unwrap() = Some((target, bytes)); - async { Ok(()) } - }) + #[tokio::test] + async fn existing_directories_and_dangling_symlinks_are_conflicts() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(dir.path().join("name")).await.unwrap(); + symlink("missing", dir.path().join("name (2)")).unwrap(); + let path = save_new_file(dir.path(), "name", b"new").await.unwrap(); + assert_eq!(path.file_name().unwrap(), "name (3)"); + assert!(dir.path().join("name").is_dir()); + assert!(fs::symlink_metadata(dir.path().join("name (2)")) + .await + .unwrap() + .is_symlink()); + no_temps(dir.path()); + } + + #[tokio::test] + async fn invalid_names_and_symlink_destination_are_refused() { + let dir = tempfile::tempdir().unwrap(); + for name in [ + "", + ".", + "..", + "../escape", + "/absolute", + "a/b", + "a\\b", + "a\0b", + ] { + assert!(save_new_file(dir.path(), name, b"bytes").await.is_err()); + } + let outside = tempfile::tempdir().unwrap(); + symlink(outside.path(), dir.path().join("Music")).unwrap(); + assert!(save_new_file(&dir.path().join("Music"), "song", b"bytes") + .await + .is_err()); + assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0); + } + + #[tokio::test] + async fn collision_limit_preserves_all_files_and_cleans_temporary_data() { + let dir = tempfile::tempdir().unwrap(); + for n in 1..=100 { + fs::write(dir.path().join(numbered_name("a.txt", n)), b"keep") + .await + .unwrap(); + } + assert!(save_new_file(dir.path(), "a.txt", b"new").await.is_err()); + for n in 1..=100 { + assert_eq!( + fs::read(dir.path().join(numbered_name("a.txt", n))) + .await + .unwrap(), + b"keep" + ); + } + no_temps(dir.path()); + } + + #[tokio::test] + async fn permission_fallback_is_exercised_without_skipping_as_root() { + let dir = tempfile::tempdir().unwrap(); + let result = save_after_direct_result( + Err(std::io::ErrorKind::PermissionDenied.into()), + dir.path(), + "a", + b"abc", + |dir, name, bytes| async move { + assert_eq!(bytes, b"abc"); + Ok(dir.join(name)) + }, + ) .await .unwrap(); - assert_eq!(path, music.join("a.mp3")); - assert_eq!( - seen.into_inner().unwrap(), - Some((music.join("a.mp3"), b"abc".to_vec())) - ); - std::fs::set_permissions(&music, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!(result, dir.path().join("a")); + assert!(save_after_direct_result( + Err(std::io::ErrorKind::PermissionDenied.into()), + dir.path(), + "a", + b"abc", + |_, _, _| async { anyhow::bail!("namespace unavailable") } + ) + .await + .unwrap_err() + .to_string() + .contains("namespace")); + assert!(save_after_direct_result( + Err(std::io::ErrorKind::StorageFull.into()), + dir.path(), + "a", + b"abc", + |_, _, _| async { panic!("disk full must not trigger permission fallback") } + ) + .await + .is_err()); + } + + async fn run_script( + dir: &Path, + name: &str, + bytes: &[u8], + expected: usize, + ) -> std::process::Output { + use tokio::io::AsyncWriteExt; + let mut child = tokio::process::Command::new("sh") + .args(["-c", WRITE_VIA_USERNS, "sh"]) + .arg(dir) + .arg(name) + .arg(expected.to_string()) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + let mut input = child.stdin.take().unwrap(); + input.write_all(bytes).await.unwrap(); + drop(input); + child.wait_with_output().await.unwrap() } #[tokio::test] - async fn a_failed_fallback_is_reported() { - use std::os::unix::fs::PermissionsExt; + async fn namespace_script_preserves_names_bytes_modes_and_existing_entries() { let dir = tempfile::tempdir().unwrap(); - std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o555)).unwrap(); - if std::fs::File::create(dir.path().join("probe")).is_ok() { - return; + let folder = dir.path().join("Music"); + let name = "song ' $() ; #.mp3"; + for n in 1..=2 { + let output = run_script(&folder, name, b"abc", 3).await; + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let chosen = String::from_utf8(output.stdout).unwrap(); + assert_eq!(chosen, numbered_name(name, n)); + let path = folder.join(chosen); + assert_eq!(fs::read(&path).await.unwrap(), b"abc"); + assert_eq!( + fs::metadata(path).await.unwrap().permissions().mode() & 0o777, + 0o644 + ); } - let err = save_new_file_with(dir.path(), "a.mp3", b"abc", |_, _| async { - anyhow::bail!("no podman") - }) - .await - .unwrap_err(); - assert!(format!("{err:#}").contains("no podman")); - std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap(); + no_temps(&folder); + } + + #[tokio::test] + async fn namespace_script_refuses_truncated_input_and_cleans_up() { + let dir = tempfile::tempdir().unwrap(); + let output = run_script(dir.path(), "never.bin", b"partial", 100).await; + assert!(!output.status.success()); + assert!(!dir.path().join("never.bin").exists()); + no_temps(dir.path()); + } + + #[tokio::test] + async fn namespace_script_does_not_link_inside_existing_directory() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(dir.path().join("name")).await.unwrap(); + symlink("missing", dir.path().join("name (2)")).unwrap(); + let output = run_script(dir.path(), "name", b"abc", 3).await; + assert!(output.status.success()); + assert_eq!(output.stdout, b"name (3)"); + assert_eq!( + std::fs::read_dir(dir.path().join("name")).unwrap().count(), + 0 + ); + no_temps(dir.path()); + } + + #[test] + fn names_keep_extensions_and_dotfiles() { + assert_eq!(numbered_name("a.tar.gz", 2), "a.tar (2).gz"); + assert_eq!(numbered_name(".hidden", 2), ".hidden (2)"); + assert_eq!(numbered_name("README", 2), "README (2)"); } } 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..801193ef 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -798,6 +798,10 @@ fn host_port_bindings_drifted( } async fn ensure_user_podman_socket() -> Result<()> { + // Unit tests inject a runtime; they must not restart the host Podman API. + if cfg!(test) { + return Ok(()); + } let socket_path = "/run/user/1000/podman/podman.sock"; if podman_socket_accepts_connections(socket_path).await { return Ok(()); @@ -1170,15 +1174,21 @@ impl ReconcileReport { fn cascade_pairs_for_report<'r>( report: &'r ReconcileReport, user_stopped: &std::collections::HashSet, + changed_backends: &HashSet, ) -> Vec<(&'r str, &'static str)> { let mut pairs = Vec::new(); for (backend, action) in &report.actions { if !matches!( action, - ReconcileAction::Installed | ReconcileAction::Started + ReconcileAction::NoOp | ReconcileAction::Started | ReconcileAction::Installed ) { continue; } + // A successful systemctl start can be a no-op after a transient + // Podman inspect failure. Require a witnessed lifecycle change. + if !changed_backends.contains(backend) { + continue; + } for dep in crate::app_ops::address_caching_dependents(backend) { let dep_untouched = report .actions @@ -1192,6 +1202,25 @@ fn cascade_pairs_for_report<'r>( pairs } +/// Only positive runtime evidence permits disrupting an address-caching wallet. +/// A known absent/stopped backend becoming running, a new container ID, or a +/// changed start timestamp qualifies. A failed observation never does. +fn backend_instance_changed(before: Option<&ContainerStatus>, after: &ContainerStatus) -> bool { + if after.state != ContainerState::Running || after.id.is_empty() { + return false; + } + let Some(before) = before else { + return true; + }; + if before.id.is_empty() { + return false; + } + if before.id != after.id || before.state != ContainerState::Running { + return true; + } + matches!((&before.started_at, &after.started_at), (Some(a), Some(b)) if !a.is_empty() && !b.is_empty() && a != b) +} + #[derive(Debug, Default)] pub struct AdoptionReport { pub adopted: Vec, @@ -1905,14 +1934,40 @@ impl ProdContainerOrchestrator { _ => 2, }); // Live container names (any state), for the same recovery check. - let present_containers: std::collections::HashSet = self - .runtime - .list_containers() - .await - .map(|cs| cs.into_iter().map(|c| c.name).collect()) + let listed_containers = self.runtime.list_containers().await.ok(); + let present_containers: HashSet = listed_containers + .as_ref() + .map(|cs| cs.iter().map(|c| c.name.clone()).collect()) .unwrap_or_default(); + // Keep unknown distinct from confirmed absence. Runtime queries can + // fail under load while systemd still has a healthy running backend. + let mut backend_before: HashMap> = HashMap::new(); + for lm in &manifests { + let id = &lm.manifest.app.id; + if crate::app_ops::address_caching_dependents(id).is_empty() { + continue; + } + let name = compute_container_name(&lm.manifest); + match self.runtime.get_container_status(&name).await { + Ok(status) => { + backend_before.insert(id.clone(), Some(status)); + } + Err(_) if listed_containers.is_some() && !present_containers.contains(&name) => { + backend_before.insert(id.clone(), None); + } + Err(err) => { + tracing::warn!(backend = %id, error = %err, + "cannot observe backend before reconcile; will not infer a dependency restart from an action report"); + } + } + } 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 +2007,7 @@ impl ProdContainerOrchestrator { } if mode == ReconcileMode::ExistingOnly && requires_archival_bitcoin(&app_id) - && disk_gb < ARCHIVAL_BITCOIN_DISK_GB + && bitcoin_pruned { report.record( &app_id, @@ -2087,7 +2142,20 @@ impl ProdContainerOrchestrator { // state recovery, repair recreate, boot InstallMissing) moves the // address behind a running dependent's back — §C "restart lnd after // ANY bitcoin recreate". - for (backend, dep) in cascade_pairs_for_report(&report, &user_stopped) { + let mut changed_backends = HashSet::new(); + for (backend, before) in &backend_before { + let Some(name) = container_name_by_app_id.get(backend) else { + continue; + }; + if let Ok(after) = self.runtime.get_container_status(name).await { + if backend_instance_changed(before.as_ref(), &after) { + changed_backends.insert(backend.clone()); + } + } + } + // A user stop during a slow reconcile pass still takes precedence. + let user_stopped = crate::crash_recovery::load_user_stopped(&self.data_dir).await; + for (backend, dep) in cascade_pairs_for_report(&report, &user_stopped, &changed_backends) { // Same rule as the RPC cascade: hold the dependent's op lock // across the restart; skip when a worker is mid-sequence. let lock = crate::app_ops::op_lock(dep); @@ -3226,6 +3294,9 @@ impl ProdContainerOrchestrator { } async fn ensure_container_network(&self, manifest: &AppManifest) -> Result<()> { + if cfg!(test) { + return Ok(()); + } let Some(network) = manifest.app.container.network.as_deref() else { return Ok(()); }; @@ -3720,6 +3791,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 +6155,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()); @@ -6344,6 +6468,67 @@ app: ); } + #[test] + fn backend_cascade_requires_observed_instance_change() { + let running = ContainerStatus { + id: "container-1".into(), + name: "bitcoin-core".into(), + state: ContainerState::Running, + started_at: Some("start-1".into()), + health: None, + exit_code: None, + image: "bitcoin:1".into(), + created: "created-1".into(), + ports: vec![], + lan_address: None, + }; + assert!(!backend_instance_changed(Some(&running), &running)); + assert!(backend_instance_changed(None, &running)); + let mut before = running.clone(); + before.state = ContainerState::Exited; + assert!(backend_instance_changed(Some(&before), &running)); + before = running.clone(); + before.id = "old-container".into(); + assert!(backend_instance_changed(Some(&before), &running)); + before = running.clone(); + before.started_at = Some("earlier-start".into()); + assert!(backend_instance_changed(Some(&before), &running)); + before.started_at = None; + assert!(!backend_instance_changed(Some(&before), &running)); + before.id.clear(); + assert!(!backend_instance_changed(Some(&before), &running)); + let mut after = running.clone(); + after.state = ContainerState::Exited; + assert!(!backend_instance_changed(None, &after)); + after = running.clone(); + after.id.clear(); + assert!(!backend_instance_changed(None, &after)); + } + + #[test] + fn cascade_ignores_false_started_report_but_detects_real_exec_drift() { + let none = HashSet::new(); + let mut report = ReconcileReport { + actions: vec![ + ("bitcoin-core".into(), ReconcileAction::Started), + ("lnd".into(), ReconcileAction::NoOp), + ], + failures: vec![], + }; + // systemctl start of an already active unit does not move its address. + assert!(cascade_pairs_for_report(&report, &none, &none).is_empty()); + // A unit exec rewrite can restart Bitcoin while the outer reconcile + // action remains NoOp. Runtime evidence still requires LND to reconnect. + let changed = ["bitcoin-core".into()].into(); + report.actions[0].1 = ReconcileAction::NoOp; + assert_eq!( + cascade_pairs_for_report(&report, &none, &changed), + vec![("bitcoin-core", "lnd")] + ); + report.actions[0].1 = ReconcileAction::Left("lifecycle-op-in-flight".into()); + assert!(cascade_pairs_for_report(&report, &none, &changed).is_empty()); + } + #[test] fn cascade_pairs_cover_backend_recreate_with_running_dependent() { use std::collections::HashSet; @@ -6355,6 +6540,7 @@ app: failures: vec![], }; let none = HashSet::new(); + let changed: HashSet = ["bitcoin-core".into(), "bitcoin-knots".into()].into(); // Backend recreated while lnd sat running (NoOp) → cascade. let r = report(vec![ @@ -6362,7 +6548,7 @@ app: ("lnd", ReconcileAction::NoOp), ]); assert_eq!( - cascade_pairs_for_report(&r, &none), + cascade_pairs_for_report(&r, &none, &changed), vec![("bitcoin-knots", "lnd")] ); @@ -6372,7 +6558,7 @@ app: ("lnd", ReconcileAction::NoOp), ]); assert_eq!( - cascade_pairs_for_report(&r, &none), + cascade_pairs_for_report(&r, &none, &changed), vec![("bitcoin-core", "lnd")] ); @@ -6381,7 +6567,7 @@ app: ("bitcoin-knots", ReconcileAction::NoOp), ("lnd", ReconcileAction::NoOp), ]); - assert!(cascade_pairs_for_report(&r, &none).is_empty()); + assert!(cascade_pairs_for_report(&r, &none, &none).is_empty()); // Dependent itself (re)started this pass → it already resolved the // fresh address; no cascade. @@ -6389,7 +6575,7 @@ app: ("bitcoin-knots", ReconcileAction::Installed), ("lnd", ReconcileAction::Started), ]); - assert!(cascade_pairs_for_report(&r, &none).is_empty()); + assert!(cascade_pairs_for_report(&r, &none, &changed).is_empty()); // User-stopped dependent is never bounced. let r = report(vec![ @@ -6397,14 +6583,14 @@ app: ("lnd", ReconcileAction::NoOp), ]); let stopped: HashSet = ["lnd".to_string()].into(); - assert!(cascade_pairs_for_report(&r, &stopped).is_empty()); + assert!(cascade_pairs_for_report(&r, &stopped, &changed).is_empty()); // Non-backend recreates don't cascade anything. let r = report(vec![ ("grafana", ReconcileAction::Installed), ("lnd", ReconcileAction::NoOp), ]); - assert!(cascade_pairs_for_report(&r, &none).is_empty()); + assert!(cascade_pairs_for_report(&r, &none, &changed).is_empty()); } #[tokio::test] diff --git a/core/archipelago/src/container/quadlet.rs b/core/archipelago/src/container/quadlet.rs index 00a5b7fc..a6150398 100644 --- a/core/archipelago/src/container/quadlet.rs +++ b/core/archipelago/src/container/quadlet.rs @@ -184,6 +184,7 @@ pub struct QuadletUnit { pub no_new_privileges: bool, pub cpu_quota: Option, pub restart_policy: RestartPolicy, + pub stop_grace_secs: Option, } impl QuadletUnit { @@ -216,6 +217,10 @@ impl QuadletUnit { let _ = writeln!(s, "[Container]"); let _ = writeln!(s, "ContainerName={}", self.name); let _ = writeln!(s, "Image={}", self.image); + let grace = self + .stop_grace_secs + .unwrap_or_else(|| archipelago_container::runtime::stop_grace_secs_for(&self.name)); + let _ = writeln!(s, "StopTimeout={grace}"); // Pull=never: companions are pre-pulled or built. A missing image // must surface as a unit start failure, not a silent retry storm. let _ = writeln!(s, "Pull=never"); @@ -350,6 +355,15 @@ impl QuadletUnit { // the unit stuck in deactivating. Health/status remains app-level state, // not a systemd start gate. let _ = writeln!(s, "TimeoutStartSec=0"); + let _ = writeln!(s, "TimeoutStopSec={}", grace.saturating_add(15)); + // Stop explicitly before Quadlet's generated `podman rm -f`. The + // existing container may still carry Podman's old 10-second default; + // StopTimeout alone only protects containers created after migration. + let _ = writeln!(s, "ExecStop="); + let _ = writeln!( + s, + "ExecStop=/usr/bin/podman stop --ignore --time={grace} --cidfile=%t/%N.cid" + ); // Restart policy + 10s backoff. RestartSec keeps a crash-loop // from saturating the journal. Companions: Always. Backends: // OnFailure (clean stops stay stopped). @@ -525,6 +539,9 @@ impl QuadletUnit { // Always, not OnFailure: with quadlet's `--rm`, OnFailure left a // cleanly-exited app deleted and unrestarted. See RestartPolicy. restart_policy: RestartPolicy::Always, + stop_grace_secs: Some(super::prod_orchestrator::resolve_stop_grace_secs( + manifest, name, + )), } } } @@ -676,6 +693,13 @@ pub async fn unit_exists(name: &str) -> bool { /// Resolve the per-user quadlet dir under $HOME. Created if missing. pub async fn unit_dir() -> Result { + #[cfg(test)] + { + static TEST_UNITS: std::sync::OnceLock = std::sync::OnceLock::new(); + return Ok(TEST_UNITS + .get_or_init(|| tempfile::tempdir().unwrap().keep()) + .clone()); + } let home = std::env::var_os("HOME") .map(PathBuf::from) .ok_or_else(|| anyhow!("HOME not set; cannot locate quadlet unit dir"))?; @@ -785,7 +809,11 @@ pub async fn stop_service(service: &str) -> Result<()> { /// corruption — so the orchestrator passes the per-app grace here. Never waits /// less than `QUADLET_STOP_TIMEOUT`. pub async fn stop_service_with_timeout(service: &str, timeout: Duration) -> Result<()> { - let timeout = timeout.max(QUADLET_STOP_TIMEOUT); + let name = service.strip_suffix(".service").unwrap_or(service); + let body = fs::read_to_string(unit_dir().await?.join(format!("{name}.container"))) + .await + .unwrap_or_default(); + let timeout = timeout.max(stop_wait_timeout(name, &body)); match systemctl_user_status(&["stop", service], timeout).await { Ok(status) if status.success() => Ok(()), Ok(status) => Err(anyhow!("systemctl --user stop {service} exited {status}")), @@ -806,10 +834,29 @@ pub async fn stop_service_with_timeout(service: &str, timeout: Duration) -> Resu } } +/// The command waiter must outlive both the container grace and systemd's +/// stop deadline. Restart/repair callers must not kill Bitcoin at 45 seconds. +fn stop_wait_timeout(name: &str, unit_body: &str) -> Duration { + Duration::from_secs(stop_grace_from_unit(name, unit_body).saturating_add(30)) + .max(QUADLET_STOP_TIMEOUT) +} + +fn stop_grace_from_unit(name: &str, unit_body: &str) -> u64 { + directive_values(unit_body, "StopTimeout=") + .last() + .and_then(|value| value.parse::().ok()) + .unwrap_or_else(|| archipelago_container::runtime::stop_grace_secs_for(name)) +} + async fn systemctl_user_status( args: &[&str], timeout: Duration, ) -> Result { + #[cfg(test)] + { + use std::os::unix::process::ExitStatusExt; + return Ok(std::process::ExitStatus::from_raw(0)); + } let mut cmd = Command::new("systemctl"); cmd.arg("--user").args(args); cmd.kill_on_drop(true); @@ -856,6 +903,10 @@ async fn wait_not_deactivating(service: &str, timeout: Duration) -> bool { } async fn systemctl_user_output(args: &[&str], timeout: Duration) -> Result { + #[cfg(test)] + { + anyhow::bail!("Unit tests have no real user service manager"); + } let mut cmd = Command::new("systemctl"); cmd.arg("--user").args(args); cmd.kill_on_drop(true); @@ -923,6 +974,10 @@ fn directive_values(unit_body: &str, prefix: &str) -> Vec { /// that systemd no longer knows about. pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> { let svc = format!("{unit_name}.service"); + let path = dir.join(format!("{unit_name}.container")); + let body = fs::read_to_string(&path).await.unwrap_or_default(); + let timeout = stop_wait_timeout(unit_name, &body); + let grace = stop_grace_from_unit(unit_name, &body).to_string(); // Stop first; ignore failure (unit may already be down). BOUNDED — on // rootless podman a generated unit can wedge in "deactivating" while // `podman rm -f` hangs underneath it, and an unbounded `systemctl stop` @@ -930,13 +985,12 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> { // the package entry is stranded in `Removing` (a ghost in My Apps that also // blocks reinstall). If the graceful stop times out, escalate to // SIGKILL + reset-failed so teardown always proceeds. - if systemctl_user_status(&["stop", &svc], QUADLET_STOP_TIMEOUT) + if systemctl_user_status(&["stop", &svc], timeout) .await .is_err() { let _ = kill_and_reset_service(&svc).await; } - let path = dir.join(format!("{unit_name}.container")); if fs::try_exists(&path).await.unwrap_or(false) { match fs::remove_file(&path).await { Ok(()) => {} @@ -949,9 +1003,9 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> { // Bounded so a hung podman store can't re-introduce the stall this function // exists to avoid. let _ = tokio::time::timeout( - QUADLET_STOP_TIMEOUT, + timeout, Command::new("podman") - .args(["rm", "-f", unit_name]) + .args(["rm", "-f", "--ignore", "--time", &grace, unit_name]) .status(), ) .await; @@ -960,6 +1014,9 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> { /// Is the quadlet-generated service currently active? pub async fn is_active(service: &str) -> bool { + if cfg!(test) { + return false; + } Command::new("systemctl") .args(["--user", "is-active", "--quiet", service]) .status() @@ -973,6 +1030,118 @@ mod tests { use super::*; use tempfile::tempdir; + #[test] + fn shutdown_grace_covers_container_systemd_and_caller() { + for (name, grace) in [ + ("bitcoin-core", 600), + ("bitcoin-knots", 600), + ("lnd", 330), + ("electrumx", 300), + ("other", 30), + ] { + let unit = QuadletUnit { + name: name.into(), + ..Default::default() + }; + let body = unit.render(); + assert!(body.contains(&format!("StopTimeout={grace}\n"))); + assert!(body.contains(&format!("TimeoutStopSec={}\n", grace + 15))); + assert!(body.contains(&format!("podman stop --ignore --time={grace} --cidfile="))); + assert_eq!( + stop_wait_timeout(name, &body), + Duration::from_secs(grace + 30) + ); + // Legacy units have no StopTimeout directive yet. + assert_eq!(stop_wait_timeout(name, ""), Duration::from_secs(grace + 30)); + } + } + + #[test] + fn custom_stop_grace_survives_render_and_restart_budget() { + let manifest: AppManifest = serde_yaml::from_str( + r#" +app: + id: custom-db + name: Custom database + version: 1.0.0 + stop_grace_secs: 900 + container: + image: example/db:1 +"#, + ) + .unwrap(); + let unit = QuadletUnit::from_manifest(&manifest, "custom-db"); + assert_eq!(unit.stop_grace_secs, Some(900)); + assert_eq!( + stop_wait_timeout("custom-db", &unit.render()), + Duration::from_secs(930) + ); + assert_eq!( + stop_wait_timeout("lnd", "StopTimeout=invalid"), + Duration::from_secs(360) + ); + } + + #[test] + fn stop_grace_migration_does_not_request_an_execution_restart() { + let unit = sample_unit(); + let new = unit.render(); + let old = new + .lines() + .filter(|line| { + !line.starts_with("StopTimeout=") + && !line.starts_with("TimeoutStopSec=") + && !line.starts_with("ExecStop=") + }) + .collect::>() + .join("\n"); + assert!(!exec_changed(&old, &new)); + assert!(!publish_ports_changed(&old, &new)); + assert!(!network_aliases_changed(&old, &new)); + assert!(!health_cmd_changed(&old, &new)); + } + + #[test] + fn actual_quadlet_generator_stops_before_forced_removal() { + let generator = Path::new("/usr/lib/systemd/system-generators/podman-system-generator"); + if !generator.exists() { + eprintln!( + "Quadlet generator unavailable; run this regression on the Linux release host" + ); + return; + } + let dir = tempdir().unwrap(); + let unit = QuadletUnit { + name: "grace-test".into(), + image: "localhost/test:latest".into(), + stop_grace_secs: Some(600), + ..Default::default() + }; + std::fs::write(dir.path().join("grace-test.container"), unit.render()).unwrap(); + let output = std::process::Command::new(generator) + .args(["--user", "--dryrun"]) + .env("QUADLET_UNIT_DIRS", dir.path()) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let generated = String::from_utf8_lossy(&output.stdout).to_string() + + &String::from_utf8_lossy(&output.stderr); + let stop = generated + .find("ExecStop=/usr/bin/podman stop --ignore --time=600") + .unwrap(); + let remove = generated.find("ExecStop=/usr/bin/podman rm ").unwrap(); + assert!( + stop < remove, + "Legacy container must stop gracefully before removal" + ); + assert!(generated.contains("--stop-timeout 600")); + assert!(generated.contains("TimeoutStopSec=615")); + } + #[test] fn render_emits_secret_env_by_reference_never_value() { let u = QuadletUnit { 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/update.rs b/core/archipelago/src/update.rs index 51455303..8c27b1f9 100644 --- a/core/archipelago/src/update.rs +++ b/core/archipelago/src/update.rs @@ -1481,6 +1481,21 @@ pub async fn cancel_download(data_dir: &Path) -> Result<()> { /// service unit that inherits systemd's default protections (i.e. none /// of ours), escaping the namespace. pub(crate) async fn host_sudo(args: &[&str]) -> Result { + #[cfg(test)] + { + anyhow::ensure!( + std::env::var("ARCHY_TEST_ISOLATED").as_deref() == Ok("1"), + "Host-operation tests require scripts/test-backend-isolated.sh" + ); + let (program, args) = args.split_first().context("Missing test command")?; + // Run inside the test namespace, never escape through sudo/systemd-run. + return tokio::process::Command::new(program) + .args(args) + .status() + .await + .context("isolated test command failed"); + } + let mut full: Vec<&str> = vec![ "systemd-run", "--wait", @@ -1505,6 +1520,21 @@ pub(crate) async fn host_sudo(args: &[&str]) -> Result /// Same mechanism as `host_sudo` but captures stdout — for read-only probes /// (e.g. `stat`) where the answer is in the output, not the exit status. pub(crate) async fn host_sudo_output(args: &[&str]) -> Result { + #[cfg(test)] + { + anyhow::ensure!( + std::env::var("ARCHY_TEST_ISOLATED").as_deref() == Ok("1"), + "Host-operation tests require scripts/test-backend-isolated.sh" + ); + let (program, args) = args.split_first().context("Missing test command")?; + // Run inside the test namespace, never escape through sudo/systemd-run. + return tokio::process::Command::new(program) + .args(args) + .output() + .await + .context("isolated test command failed"); + } + let mut full: Vec<&str> = vec![ "systemd-run", "--wait", 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..64dd2ad9 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,22 +1281,23 @@ 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)); - setText('liqRemote', fmtAmount(lnRemote)); + const liquidityReady = haveChan && !state.chanbalStale && !!state.info; + setText('liqLocal', liquidityReady ? fmtAmount(lnLocal) : '—'); + setText('liqRemote', liquidityReady ? fmtAmount(lnRemote) : '—'); const total = lnLocal + lnRemote; const localPct = total > 0 ? (lnLocal / total) * 100 : 50; - document.getElementById('liqBarLocal').style.width = localPct + '%'; - document.getElementById('liqBarRemote').style.width = (100 - localPct) + '%'; - setText('liqHint', total > 0 + document.getElementById('liqBarLocal').style.width = (liquidityReady ? localPct : 0) + '%'; + document.getElementById('liqBarRemote').style.width = (liquidityReady ? 100 - localPct : 0) + '%'; + setText('liqHint', !liquidityReady ? 'Channel capacity is unavailable while waiting for LND.' : total > 0 ? Math.round(localPct) + '% of your channel capacity is outbound (sendable).' : 'Open a channel to start sending and receiving over Lightning.'); } @@ -1284,6 +1313,15 @@ function renderSummary() { const g = state.info; + if (!g) { + for (const id of ['statPeers', 'statActiveChannels', 'statCapacity', 'statRoutingMonth', 'healthHeight', 'healthPending', 'chActive', 'chInactive', 'chPending', 'chCapacity']) setText(id, '—'); + for (const id of ['statChannelsSub', 'channelsLinkSub']) setText(id, 'Waiting for LND'); + for (const id of ['healthChain', 'healthGraph']) { + const pill = document.getElementById(id); + pill.textContent = '—'; pill.className = 'pill warn'; + } + return; + } const chans = state.channels; const active = chans.filter(c => c.active).length; const inactive = chans.length - active; @@ -1321,6 +1359,10 @@ function renderChannels() { const el = document.getElementById('channelList'); if (!el) return; + if (!state.info) { + el.innerHTML = '
Waiting for LND. Existing channels will appear when it is ready.
'; + return; + } const q = (document.getElementById('channelFilter').value || '').toLowerCase(); let list = state.channels.slice(); if (q) list = list.filter(c => String(c.remote_pubkey || '').toLowerCase().includes(q) || String(c.chan_id || '').includes(q)); diff --git a/docs/TODO.md b/docs/TODO.md index 1db93d91..6e5d53fb 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -3,14 +3,47 @@ Working backlog of forward-looking items not yet scoped into a dedicated plan doc. See [`ROADMAP.md`](ROADMAP.md) for the curated, public-facing direction. -## Blocking incident — before unrelated work +## Framework incident — closed with operator acceptance -- **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. +- **CLOSED WITH OPERATOR ACCEPTANCE (2026-09-30): Framework LND startup / + missing Receive address / false zero balance.** Startup, native balances, + Cashu address and source integration were verified; the operator accepted the + remaining display check and authorized release. 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. +## Next release after 1.8.21 — reported 2026-09-30 + +- [ ] **ThinkPad X250 kiosk: Bitcoin installation version selector is unreadable + and appears underneath the pruning information.** Operator reports white + styling with invisible text on the actual kiosk; the same flow works in remote + Brave. Reproduce on the X250's kiosk engine and record its version, display + scale and resolution. Inspect the native `