fix(ecash): pay the mint's input fee, and stop a damaged wallet from being erased

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 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-17 07:01:45 -04:00
co-authored by Claude Fable 5
parent 26638aa621
commit ffec7d3114
3 changed files with 204 additions and 10 deletions
@@ -405,6 +405,35 @@ impl MintClient {
pub async fn swap(&self, inputs: &[Proof], target_amounts: &[u64]) -> Result<SwapResult> {
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<u64>;
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();