From 3f52e4cd789909ba27fb1329410670b042d0c5d4 Mon Sep 17 00:00:00 2001 From: ssmithx Date: Tue, 8 Sep 2026 15:34:28 +0000 Subject: [PATCH] fix(ecash): recover from a truncated/corrupt Minibits state file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit archy-x250-pa3's data volume filled to 100% (cuprate at 125G, since removed) while a client had the ecash receive tab open. save_state's write landed mid-truncate, leaving wallet/minibits.json at 0 bytes. load_state then hard-failed every wallet.ecash-lnaddress call with "EOF while parsing a value", surfaced in the UI as "Lightning address unavailable" — permanently, since nothing ever cleared the bad file. Registration is idempotent per pubkey (re-registering returns the same lud16 Minibits already assigned), so there's no reason a corrupt local mirror of that state should be fatal. load_state now treats an empty or unparseable state file the same as a missing one — re-register and recover the same address — instead of erroring. Manually cleared the stuck file on archy-x250-pa3 as an immediate fix; this closes the gap so it self-heals next time. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a --- core/archipelago/src/wallet/minibits.rs | 55 +++++++++++++++++++++---- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/core/archipelago/src/wallet/minibits.rs b/core/archipelago/src/wallet/minibits.rs index dac7815e..713784d2 100644 --- a/core/archipelago/src/wallet/minibits.rs +++ b/core/archipelago/src/wallet/minibits.rs @@ -191,15 +191,26 @@ fn state_path(data_dir: &Path) -> std::path::PathBuf { async fn load_state(data_dir: &Path) -> Result> { let path = state_path(data_dir); match fs::read_to_string(&path).await { - Ok(s) => { - let st: MinibitsState = serde_json::from_str(&s) - .with_context(|| format!("Failed to parse {}", path.display()))?; - if st.wallet_id.is_empty() { + Ok(s) if s.trim().is_empty() => Ok(None), + Ok(s) => match serde_json::from_str::(&s) { + Ok(st) if st.wallet_id.is_empty() => Ok(None), + Ok(st) => Ok(Some(st)), + // Unlike the accepted-mints file, nothing here is a user-editable + // security setting — it's a pure mirror of state Minibits already + // holds server-side, and registration is idempotent per pubkey + // (§ module docs), so re-registering after a corrupt/truncated + // read always recovers the *same* address. A node whose disk + // filled up mid-write (observed on archy-x250-pa3, 2026-09-08: + // this file truncated to 0 bytes) must self-heal on the next open + // rather than permanently show "Lightning address unavailable". + Err(e) => { + warn!( + "Minibits: {} is corrupt/unreadable ({e}); treating as no profile yet and re-registering", + path.display() + ); Ok(None) - } else { - Ok(Some(st)) } - } + }, Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(e) => Err(e).with_context(|| format!("Failed to read {}", path.display())), } @@ -728,6 +739,36 @@ mod tests { assert_eq!(accepted.mints.iter().filter(|m| *m == mint).count(), 1); } + #[tokio::test] + async fn load_state_treats_empty_file_as_no_profile() { + // Reproduces archy-x250-pa3, 2026-09-08: a disk-full write truncated + // wallet/minibits.json to 0 bytes, which then made every + // wallet.ecash-lnaddress call fail with "EOF while parsing a value" + // instead of just re-registering (idempotent per pubkey, so safe). + let tmp = tempfile::TempDir::new().unwrap(); + let path = tmp.path().join(STATE_FILE); + tokio::fs::create_dir_all(path.parent().unwrap()) + .await + .unwrap(); + tokio::fs::write(&path, b"").await.unwrap(); + + let st = load_state(tmp.path()).await.unwrap(); + assert!(st.is_none()); + } + + #[tokio::test] + async fn load_state_treats_corrupt_json_as_no_profile() { + let tmp = tempfile::TempDir::new().unwrap(); + let path = tmp.path().join(STATE_FILE); + tokio::fs::create_dir_all(path.parent().unwrap()) + .await + .unwrap(); + tokio::fs::write(&path, b"{ not valid json").await.unwrap(); + + let st = load_state(tmp.path()).await.unwrap(); + assert!(st.is_none()); + } + /// Live end-to-end against the production Minibits API: register a throwaway /// profile with a random ecash phrase and claim (nothing pending → 0). Run /// with `cargo test -- --ignored --nocapture`. It creates one disposable