fix(ecash): recover from a truncated/corrupt Minibits state file

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
This commit is contained in:
2026-09-08 15:34:28 +00:00
co-authored by Claude Sonnet 5
parent 76d565fb18
commit 3f52e4cd78
+48 -7
View File
@@ -191,15 +191,26 @@ fn state_path(data_dir: &Path) -> std::path::PathBuf {
async fn load_state(data_dir: &Path) -> Result<Option<MinibitsState>> {
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::<MinibitsState>(&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