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:
co-authored by
Claude Fable 5
parent
26638aa621
commit
ffec7d3114
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user