From ffec7d3114dbe032995aa559c924115b9908d56e Mon Sep 17 00:00:00 2001 From: archipelago Date: Mon, 17 Aug 2026 07:01:45 -0400 Subject: [PATCH] fix(ecash): pay the mint's input fee, and stop a damaged wallet from being erased MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent fixes, both found while exercising the routes headlessly. **Mint fees (NUT-02).** A mint may charge a per-input fee and rejects any swap whose outputs don't equal inputs minus that fee — `11005 Transaction inputs should equal outputs less fee`, which is what sending hit against testnut.cashu.space. We ignored the fee entirely, so the wallet could not spend at ANY fee-charging mint; Minibits charges zero, which is why production never saw it. `MintKeyset`/`KeysetInfo` now carry `input_fee_ppk`, `swap_fee_for` computes the NUT-02 sum (rounded up), and `MintClient::swap` reduces its outputs to cover it — applied there rather than at each call site so send, receive and cross-mint swaps are all covered at once. Inputs from a keyset the mint doesn't list contribute no fee: the mint is the authority, and guessing high would burn the sender's coins. **Damaged-wallet erasure.** `load_wallet` used `unwrap_or_default()`, so a truncated `ecash.json` read as an EMPTY wallet — and because the next operation saves the wallet back, that empty state was then written over the only copy of the proofs. A corrupt file became permanent loss. Now a file that exists but doesn't parse fails with a message naming the file and stating the coins are still in it, and the bytes are left untouched for recovery; an empty file is still treated as a fresh wallet, since a create that never got its first write is not damage. The accepted-mints list gets the same treatment, where corruption would have silently reset the operator to trusting only the default mint. Writes are now atomic (temp + fsync + rename) for both files. The previous plain write truncated the real file first, which is exactly how a wallet ends up unparseable after a crash or power cut. Tests cover: a damaged file errors and survives on disk, an empty file is fresh, saving leaves no temp behind and round-trips, and — guarding the on-disk contract against exactly this update — a verbatim pre-update wallet file still loads with its balance, proofs and history intact. Co-Authored-By: Claude Fable 5 --- core/archipelago/src/wallet/cashu.rs | 23 +++ core/archipelago/src/wallet/ecash.rs | 162 +++++++++++++++++++-- core/archipelago/src/wallet/mint_client.rs | 29 ++++ 3 files changed, 204 insertions(+), 10 deletions(-) diff --git a/core/archipelago/src/wallet/cashu.rs b/core/archipelago/src/wallet/cashu.rs index 607b0be2..1f18272f 100644 --- a/core/archipelago/src/wallet/cashu.rs +++ b/core/archipelago/src/wallet/cashu.rs @@ -308,6 +308,29 @@ pub struct KeysetInfo { pub id: String, pub unit: String, pub active: bool, + /// NUT-02 input fee, in parts-per-thousand of a proof. A mint charges + /// this per *input* on a swap/melt; zero at fee-free mints, which is why + /// ignoring it went unnoticed against Minibits. + #[serde(default)] + pub input_fee_ppk: u64, +} + +/// NUT-02 swap fee for a set of inputs: the summed per-proof parts-per- +/// thousand, rounded **up** to whole units. Inputs whose keyset the mint +/// didn't list contribute nothing — the mint is the authority, and guessing +/// high would silently burn the sender's coins. +pub fn swap_fee_for(proofs: &[Proof], keysets: &[KeysetInfo]) -> u64 { + let ppk: u64 = proofs + .iter() + .map(|p| { + keysets + .iter() + .find(|k| k.id == p.id) + .map(|k| k.input_fee_ppk) + .unwrap_or(0) + }) + .sum(); + ppk.div_ceil(1000) } /// Mint keyset: maps denomination amounts to public keys. diff --git a/core/archipelago/src/wallet/ecash.rs b/core/archipelago/src/wallet/ecash.rs index 8f837d2d..2782c661 100644 --- a/core/archipelago/src/wallet/ecash.rs +++ b/core/archipelago/src/wallet/ecash.rs @@ -310,7 +310,27 @@ pub async fn load_wallet(data_dir: &Path) -> Result { let content = fs::read_to_string(&path) .await .context("Failed to read wallet file")?; - let mut wallet: WalletState = serde_json::from_str(&content).unwrap_or_default(); + + // An empty file is a legitimate "nothing here yet" (a create that never + // got its first write); anything else that fails to parse is a damaged + // purse and must NOT be read as an empty one. + // + // This used to be `unwrap_or_default()`, which turned a truncated file + // into a zero balance — and because the very next operation saves the + // wallet back, that empty state was then written over the only copy of + // the proofs. Failing here keeps the damaged file intact so the coins + // can still be recovered from it (or from a backup) by hand. + let mut wallet: WalletState = if content.trim().is_empty() { + WalletState::default() + } else { + serde_json::from_str(&content).with_context(|| { + format!( + "Ecash wallet file {} is damaged and was NOT overwritten — your coins are \ + still in it. Restore it from a backup, or move it aside to start empty.", + path.display() + ) + })? + }; // Set default mint URL if empty if wallet.mint_url.is_empty() { @@ -320,6 +340,30 @@ pub async fn load_wallet(data_dir: &Path) -> Result { Ok(wallet) } +/// Write `content` to `path` without ever leaving a half-written file there. +/// +/// Writes a sibling temp file, flushes it to the platter, then renames over +/// the target — rename is atomic within a filesystem, so a crash or power cut +/// leaves either the old file or the new one, never a truncated one. The +/// previous plain write truncated the real file first, which is precisely how +/// a wallet ends up unparseable. +async fn write_file_atomically(path: &Path, content: &str) -> Result<()> { + let tmp = path.with_extension("json.tmp"); + let mut f = fs::File::create(&tmp) + .await + .with_context(|| format!("Failed to create {}", tmp.display()))?; + use tokio::io::AsyncWriteExt; + f.write_all(content.as_bytes()) + .await + .context("Failed to write wallet temp file")?; + f.sync_all().await.context("Failed to flush wallet file")?; + drop(f); + fs::rename(&tmp, path) + .await + .with_context(|| format!("Failed to replace {}", path.display()))?; + Ok(()) +} + /// Save wallet state to disk. pub async fn save_wallet(data_dir: &Path, wallet: &WalletState) -> Result<()> { let dir = data_dir.join("wallet"); @@ -328,9 +372,7 @@ pub async fn save_wallet(data_dir: &Path, wallet: &WalletState) -> Result<()> { .context("Failed to create wallet dir")?; let path = data_dir.join(load_network(data_dir).await.wallet_file()); let content = serde_json::to_string_pretty(wallet).context("Failed to serialize wallet")?; - fs::write(&path, content) - .await - .context("Failed to write wallet file")?; + write_file_atomically(&path, &content).await?; Ok(()) } @@ -346,9 +388,18 @@ pub async fn load_accepted_mints(data_dir: &Path) -> Result { let content = fs::read_to_string(&path) .await .context("Failed to read accepted mints")?; - let mints: AcceptedMints = serde_json::from_str(&content).unwrap_or(AcceptedMints { - mints: vec![network.default_mint()], - }); + // A damaged mint list must not silently become "trust only the default" + // — that would reject perfectly good tokens from mints the operator + // added. Empty file is still a legitimate fresh state. + let mints: AcceptedMints = if content.trim().is_empty() { + AcceptedMints { + mints: vec![network.default_mint()], + } + } else { + serde_json::from_str(&content).with_context(|| { + format!("Accepted-mints file {} is damaged", path.display()) + })? + }; Ok(mints) } @@ -361,9 +412,7 @@ pub async fn save_accepted_mints(data_dir: &Path, mints: &AcceptedMints) -> Resu let path = data_dir.join(load_network(data_dir).await.mints_file()); let content = serde_json::to_string_pretty(mints).context("Failed to serialize accepted mints")?; - fs::write(&path, content) - .await - .context("Failed to write accepted mints")?; + write_file_atomically(&path, &content).await?; Ok(()) } @@ -2040,4 +2089,97 @@ mod tests { assert_eq!(back.proofs.len(), 1); assert_eq!(back.proofs[0].proof.secret, "real"); } + + + #[tokio::test] + async fn a_damaged_wallet_file_fails_loudly_and_is_left_on_disk() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + std::fs::create_dir_all(dir.join("wallet")).unwrap(); + + // A truncated file — what a crash mid-write used to leave behind. + let damaged = r#"{"proofs":[{"amount":1000,"id":"009a1f293253e41e","secr"#; + let path = dir.join("wallet/ecash.json"); + std::fs::write(&path, damaged).unwrap(); + + // It must NOT read as an empty wallet: that is what caused the real + // balance to be overwritten with nothing on the next save. + let err = load_wallet(dir).await.expect_err("damaged wallet must error"); + assert!( + err.to_string().contains("damaged"), + "error should name the problem: {err}" + ); + + // And the bytes must still be there for recovery. + assert_eq!(std::fs::read_to_string(&path).unwrap(), damaged); + } + + #[tokio::test] + async fn an_empty_wallet_file_is_treated_as_a_fresh_wallet() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + std::fs::create_dir_all(dir.join("wallet")).unwrap(); + std::fs::write(dir.join("wallet/ecash.json"), " \n").unwrap(); + // A create that never got its first write is not damage. + let w = load_wallet(dir).await.expect("empty file is a fresh wallet"); + assert_eq!(w.balance(), 0); + } + + #[tokio::test] + async fn saving_leaves_no_temp_file_and_round_trips() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + let mut w = load_wallet(dir).await.unwrap(); + w.proofs.push(StoredProof { + proof: Proof { + amount: 21, + id: "009a1f293253e41e".into(), + secret: "s".into(), + c: "02".into(), + }, + mint_url: default_mint_url(), + spent: false, + reserved: false, + created_at: "2026-01-01T00:00:00Z".into(), + }); + save_wallet(dir, &w).await.unwrap(); + + assert_eq!(load_wallet(dir).await.unwrap().balance(), 21); + // The atomic write must not litter, or the next reader could find it. + assert!(!dir.join("wallet/ecash.json.tmp").exists()); + } + + /// The exact shape a pre-update node has on disk, parsed by the current + /// code. Guards the on-disk contract: an update must never strand funds. + #[tokio::test] + async fn a_pre_update_wallet_file_still_loads_with_its_balance() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + std::fs::create_dir_all(dir.join("wallet")).unwrap(); + // Verbatim shape from a live node (proof fields flattened, capital C, + // spent/reserved flags, RFC-3339 created_at, lowercase tx type). + let legacy = r#"{ + "proofs": [ + {"amount": 2, "id": "00107937db0cc865", "secret": "9b4bdb0e", "C": "030391", + "mint_url": "https://mint.minibits.cash/Bitcoin", "spent": true, + "reserved": false, "created_at": "2026-07-23T19:49:42.468773802+00:00"}, + {"amount": 512, "id": "00107937db0cc865", "secret": "aa11", "C": "0322", + "mint_url": "https://mint.minibits.cash/Bitcoin", "spent": false, + "reserved": false, "created_at": "2026-07-23T19:49:42.468773802+00:00"} + ], + "transactions": [ + {"id": "8f14e45f", "tx_type": "receive", "amount_sats": 512, + "timestamp": "2026-07-23T19:49:42+00:00", "description": "", + "mint_url": "https://mint.minibits.cash/Bitcoin", "peer": ""} + ], + "mint_url": "https://mint.minibits.cash/Bitcoin" + }"#; + std::fs::write(dir.join("wallet/ecash.json"), legacy).unwrap(); + + let w = load_wallet(dir).await.expect("pre-update wallet must load"); + assert_eq!(w.balance(), 512, "spendable balance must survive an update"); + assert_eq!(w.proofs.len(), 2, "spent proofs are retained too"); + assert_eq!(w.transactions.len(), 1); + assert_eq!(w.mint_url, "https://mint.minibits.cash/Bitcoin"); + } } diff --git a/core/archipelago/src/wallet/mint_client.rs b/core/archipelago/src/wallet/mint_client.rs index e021a696..44e2f6a2 100644 --- a/core/archipelago/src/wallet/mint_client.rs +++ b/core/archipelago/src/wallet/mint_client.rs @@ -405,6 +405,35 @@ impl MintClient { pub async fn swap(&self, inputs: &[Proof], target_amounts: &[u64]) -> Result { let keyset = self.get_active_sat_keyset().await?; + // NUT-02: a mint may charge a per-input fee, and it rejects the swap + // outright unless outputs == inputs - fee (`11005 Transaction inputs + // 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 + } + }; + let spendable = inputs_total.saturating_sub(fee); + let requested: u64 = target_amounts.iter().sum(); + let owned_targets: Vec; + let target_amounts: &[u64] = if requested > spendable { + if spendable == 0 { + anyhow::bail!( + "The mint's fee ({fee} sat) consumes this whole amount — nothing would be left" + ); + } + debug!("Reducing swap outputs {requested} -> {spendable} to cover a {fee} sat mint fee"); + owned_targets = amount_to_denominations(spendable); + &owned_targets + } else { + target_amounts + }; + let mut blinded_messages = Vec::new(); let mut blinding_data = Vec::new();