From 8f06d88fbffb74cb8a25f2184879d553a91c15ab Mon Sep 17 00:00:00 2001 From: archipelago Date: Sat, 20 Jun 2026 08:13:23 -0400 Subject: [PATCH] feat(wallet): pay for peer files from BOTH Cashu and Fedimint ecash (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paying for a peer file minted a Cashu-only token, so a node whose ecash balance lived in Fedimint couldn't pay even with funds. Now both backends are tried: - payer (content.download-peer-paid): mint a Cashu token first; on failure fall back to spending Fedimint notes. Only error if BOTH backends can't cover it. - seller (verify_and_receive_payment): accept Fedimint notes as well as Cashu — anything not starting with "cashu" is redeemed via reissue_into_any. - new fedimint_client::spend_from_any() — spend from whichever joined federation has the balance, returning the notes + federation id (mirrors reissue_into_any). - wallet.ecash-balance now also reports fedimint_sats + combined total_sats; the pay-for-file pre-check uses the combined total so a Fedimint-funded node isn't wrongly blocked. Compiles (cargo check + vue-tsc). Live cross-node federation validation pending (dual-ecash phase 6) — needs two nodes sharing a federation. Co-Authored-By: Claude Opus 4.8 (1M context) --- core/archipelago/src/api/rpc/content.rs | 33 +++++++-- core/archipelago/src/api/rpc/wallet.rs | 17 ++++- core/archipelago/src/wallet/ecash.rs | 18 +++++ .../archipelago/src/wallet/fedimint_client.rs | 69 +++++++++++++++++++ neode-ui/src/views/PeerFiles.vue | 10 +-- 5 files changed, 138 insertions(+), 9 deletions(-) diff --git a/core/archipelago/src/api/rpc/content.rs b/core/archipelago/src/api/rpc/content.rs index 7bf331b3..21a99d72 100644 --- a/core/archipelago/src/api/rpc/content.rs +++ b/core/archipelago/src/api/rpc/content.rs @@ -383,10 +383,35 @@ impl RpcHandler { return Err(anyhow::anyhow!("Invalid v3 onion address")); } - // Mint ecash payment token - let token_str = ecash::send_token(&self.config.data_dir, price_sats) - .await - .context("Failed to create ecash payment token — check wallet balance")?; + // Mint an ecash payment token, trying BOTH backends: Cashu first, then + // Fedimint. The seller's verify_payment_token accepts either, so a node + // whose balance lives in one system can still pay (#3). Surface the + // Cashu error only if BOTH paths fail. + let token_str = match ecash::send_token(&self.config.data_dir, price_sats).await { + Ok(t) => t, + Err(cashu_err) => { + match crate::wallet::fedimint_client::spend_from_any( + &self.config.data_dir, + price_sats, + ) + .await + { + Ok((notes, _fed_id)) => notes, + Err(fedi_err) => { + tracing::warn!( + "paid download: no ecash backend could pay {price_sats} sats \ + (cashu: {cashu_err:#}; fedimint: {fedi_err:#})" + ); + return Ok(serde_json::json!({ + "error": format!( + "Couldn't pay {price_sats} sats from your ecash wallet \ + (Cashu or Fedimint). Fund either wallet and try again." + ) + })); + } + } + } + }; let (data, _) = self.state_manager.get_snapshot().await; let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?; diff --git a/core/archipelago/src/api/rpc/wallet.rs b/core/archipelago/src/api/rpc/wallet.rs index 7c174938..6fc5ae89 100644 --- a/core/archipelago/src/api/rpc/wallet.rs +++ b/core/archipelago/src/api/rpc/wallet.rs @@ -12,8 +12,23 @@ fn is_cashu_token(token: &str) -> bool { impl RpcHandler { pub(super) async fn handle_wallet_ecash_balance(&self) -> Result { let wallet = ecash::load_wallet(&self.config.data_dir).await?; + let cashu_sats = wallet.balance(); + // Spendable Fedimint balance too, so callers (e.g. the pay-for-file + // pre-check) see funds available across BOTH backends (#3). Best-effort: + // if fmcd isn't installed/joined this is just 0, never an error. + let fedimint_sats = match fedimint_client::FedimintClient::from_node(&self.config.data_dir) + .await + { + Ok(client) => client.total_balance_sats().await.unwrap_or(0), + Err(_) => 0, + }; Ok(serde_json::json!({ - "balance_sats": wallet.balance(), + // `balance_sats` stays Cashu-only for back-compat; `total_sats` is the + // spendable amount across Cashu + Fedimint. + "balance_sats": cashu_sats, + "cashu_sats": cashu_sats, + "fedimint_sats": fedimint_sats, + "total_sats": cashu_sats + fedimint_sats, "proof_count": wallet.proofs.iter().filter(|p| !p.spent && !p.reserved).count(), "mint_url": wallet.mint_url, })) diff --git a/core/archipelago/src/wallet/ecash.rs b/core/archipelago/src/wallet/ecash.rs index c889e620..49b14d45 100644 --- a/core/archipelago/src/wallet/ecash.rs +++ b/core/archipelago/src/wallet/ecash.rs @@ -1118,6 +1118,24 @@ pub async fn verify_and_receive_payment( return Ok(received); } + // Fedimint notes (#3): a buyer whose balance is in Fedimint pays with notes + // rather than a Cashu token. Cashu tokens all start with "cashu" (cashuA/ + // cashuB, or the legacy form handled above), so anything else is treated as + // Fedimint notes and redeemed into a joined federation. reissue_into_any + // verifies the notes are unspent and credits this node's wallet. + if !token_str.starts_with("cashu") { + let (received, _fed_id) = + crate::wallet::fedimint_client::reissue_into_any(data_dir, token_str).await?; + if received < required_sats { + anyhow::bail!( + "Insufficient payment: {} sats, need {} sats", + received, + required_sats + ); + } + return Ok(received); + } + // Parse and validate cashuA token let token = CashuToken::deserialize(token_str)?; let total = token.total_amount(); diff --git a/core/archipelago/src/wallet/fedimint_client.rs b/core/archipelago/src/wallet/fedimint_client.rs index fb785a65..dd57e980 100644 --- a/core/archipelago/src/wallet/fedimint_client.rs +++ b/core/archipelago/src/wallet/fedimint_client.rs @@ -191,6 +191,75 @@ pub async fn ensure_default_federation(data_dir: &Path) -> Result<()> { Ok(()) } +/// Spend `amount_sats` of Fedimint ecash from whichever joined federation can +/// cover it, returning the serialized notes (the X-Payment-Token a buyer hands +/// the seller) and the federation id that minted them. Federations are tried in +/// registry order (default first); only one with sufficient balance is used so +/// the resulting notes redeem cleanly on the other side. Errors clearly when no +/// federation is joined or none has the balance — the caller falls back to (or +/// from) the Cashu path. +pub async fn spend_from_any(data_dir: &Path, amount_sats: u64) -> Result<(String, String)> { + if amount_sats == 0 { + anyhow::bail!("payment amount must be greater than zero"); + } + let _ = ensure_default_federation(data_dir).await; + let client = FedimintClient::from_node(data_dir).await?; + + // Same union-of-sources approach as reissue_into_any: the persisted registry + // and what fmcd actually reports joined can drift, so consider both. + let mut fed_ids: Vec = Vec::new(); + if let Ok(reg) = load_registry(data_dir).await { + for f in reg.federations { + if !fed_ids.contains(&f.federation_id) { + fed_ids.push(f.federation_id); + } + } + } + for id in client.joined_federation_ids().await { + if !fed_ids.contains(&id) { + fed_ids.push(id); + } + } + if fed_ids.is_empty() { + anyhow::bail!("No Fedimint federation joined to spend from"); + } + + let mut last_err = None; + for fed_id in &fed_ids { + // Skip federations that can't cover the amount so we don't mint a + // partial/failed spend and leave dangling reserved notes. + match client.federation_balance_sats(fed_id).await { + Ok(bal) if bal >= amount_sats => {} + Ok(_) => continue, + Err(e) => { + last_err = Some(e); + continue; + } + } + match client.spend(fed_id, amount_sats).await { + Ok(notes) => { + record_fedimint_tx( + data_dir, + crate::wallet::ecash::TransactionType::Send, + amount_sats, + fed_id, + "Sent Fedimint ecash", + ) + .await; + return Ok((notes, fed_id.clone())); + } + Err(e) => last_err = Some(e), + } + } + Err(last_err + .map(|e| anyhow::anyhow!("Fedimint spend failed across all federations: {e}")) + .unwrap_or_else(|| { + anyhow::anyhow!( + "No joined Fedimint federation has {amount_sats} sats available" + ) + })) +} + /// Redeem received Fedimint notes into a joined federation. fmcd's reissue is /// per-federation, but a token only validates against the federation that /// minted it, so we try each joined federation (default first) and return the diff --git a/neode-ui/src/views/PeerFiles.vue b/neode-ui/src/views/PeerFiles.vue index bff0a842..74d6e10e 100644 --- a/neode-ui/src/views/PeerFiles.vue +++ b/neode-ui/src/views/PeerFiles.vue @@ -916,14 +916,16 @@ async function payWithEcash() { downloading.value = item.id purchaseError.value = null try { - // Check ecash balance first + // Check ecash balance first — across BOTH backends (Cashu + Fedimint), since + // the payment tries each in turn (#3). Fall back to the Cashu-only field for + // older backends that don't report `total_sats`. try { - const balanceRes = await rpcClient.call<{ balance_sats?: number }>({ + const balanceRes = await rpcClient.call<{ total_sats?: number; balance_sats?: number }>({ method: 'wallet.ecash-balance', }) - const balance = balanceRes?.balance_sats ?? 0 + const balance = balanceRes?.total_sats ?? balanceRes?.balance_sats ?? 0 if (balance < price) { - purchaseError.value = `Insufficient ecash balance (${balance} sats). Need ${price} sats. Fund your wallet, or pay from another wallet via QR.` + purchaseError.value = `Insufficient ecash balance (${balance} sats across Cashu + Fedimint). Need ${price} sats. Fund either wallet, or pay from another wallet via QR.` closePayModal() return }