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
+23
View File
@@ -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.