From 98e15b3af062d6a0a379e4d44cff33db5cdab5e7 Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 24 Jul 2026 13:34:48 +0100 Subject: [PATCH] =?UTF-8?q?fix(ecash):=20change=20proofs=20no=20longer=20r?= =?UTF-8?q?ide=20along=20inside=20sent=20tokens=20=E2=80=94=20double-credi?= =?UTF-8?q?t=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit send_token_at split the mint swap's results with partition(send_denoms.contains(amount)) — membership, not count. When a change denomination equaled a send denomination (guaranteed when spending from a proof worth exactly 2x the amount: send [n], change [n]), the change proofs were serialized INTO the token, and the receiver redeemed double. Both the wallet send flow and peer-files purchases mint through this path. The split now consumes exactly one proof per required send denomination (everything else returns to the wallet as change) and hard-fails if the mint returns an incomplete denomination set, so a token can never again be silently over- or under-packed. Note: tokens minted BEFORE this fix already contain the extra proofs and will still redeem at their inflated value. Co-Authored-By: Claude Fable 5 --- core/archipelago/src/wallet/ecash.rs | 29 +++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/core/archipelago/src/wallet/ecash.rs b/core/archipelago/src/wallet/ecash.rs index 49b14d45..c374ee3f 100644 --- a/core/archipelago/src/wallet/ecash.rs +++ b/core/archipelago/src/wallet/ecash.rs @@ -632,11 +632,30 @@ pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) -> // Mark original proofs as spent wallet.mark_spent(&indices); - // Separate send proofs from change proofs - let (send, change): (Vec<_>, Vec<_>) = swap_result - .new_proofs - .into_iter() - .partition(|p| send_denoms.contains(&p.amount)); + // Separate send proofs from change proofs BY COUNT, not membership: + // partition(contains) put EVERY proof whose denomination appeared in + // send_denoms into the token — when change shared a denomination with + // the send (worst case: spending from a proof worth exactly 2× the + // amount, send [n] + change [n]), the change proofs rode along and the + // receiver was credited double. Consume exactly one proof per needed + // send denomination; everything else is change. + let mut send_needed = send_denoms.clone(); + let mut send: Vec = Vec::new(); + let mut change: Vec = Vec::new(); + for p in swap_result.new_proofs { + if let Some(pos) = send_needed.iter().position(|&d| d == p.amount) { + send_needed.swap_remove(pos); + send.push(p); + } else { + change.push(p); + } + } + if !send_needed.is_empty() { + anyhow::bail!( + "Mint swap returned incomplete send denominations (missing {:?})", + send_needed + ); + } // Add change proofs back to wallet if !change.is_empty() { -- 2.47.2