From 12f54e390d347cd151476ae67fc72e673ec470b5 Mon Sep 17 00:00:00 2001 From: archipelago Date: Sat, 20 Jun 2026 12:16:02 -0400 Subject: [PATCH] feat(wallet): ecash pay confirmation screen + auto-refund on failed sale (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PeerFiles: new confirmation step after "pay from ecash" — shows the amount and which wallet will be spent (Cashu/Fedimint) with balances, lets the user switch backends, and a styled Confirm button. The chosen backend is passed to the payment so it spends exactly what was confirmed. - content.download-peer-paid: accept `method` (cashu|fedimint) to honor the confirmed choice; log the backend + outcome; backend-specific rejection errors ("not in the same Fedimint federation" / "doesn't accept your Cashu mint"). - AUTO-REFUND: a minted token whose sale fails (peer unreachable, rejected, or error) is now reclaimed (fedimint reissue / cashu receive) so the buyer no longer loses the spent ecash — fixes the stuck-Fedimint-notes report. - wallet.ecash-balance already reports cashu_sats/fedimint_sats/total_sats which the confirm screen uses to pick/show the covering wallet. Co-Authored-By: Claude Opus 4.8 (1M context) --- core/archipelago/src/api/rpc/content.rs | 125 +++++++++++++++----- neode-ui/src/views/PeerFiles.vue | 146 +++++++++++++++++++----- 2 files changed, 214 insertions(+), 57 deletions(-) diff --git a/core/archipelago/src/api/rpc/content.rs b/core/archipelago/src/api/rpc/content.rs index 21a99d72..a6034510 100644 --- a/core/archipelago/src/api/rpc/content.rs +++ b/core/archipelago/src/api/rpc/content.rs @@ -19,6 +19,29 @@ fn is_valid_v3_onion(addr: &str) -> bool { const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-catalog/v1"; +/// Best-effort reclaim of an ecash payment token that was minted but the sale +/// didn't complete (seller unreachable or couldn't redeem it), so the buyer +/// doesn't lose the value. For Fedimint the spender can reissue its own +/// un-redeemed notes; for Cashu the proofs are received back. Fails silently if +/// the seller already claimed the token (then the value is genuinely gone). +async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) { + let res = match backend { + "fedimint" => crate::wallet::fedimint_client::reissue_into_any(data_dir, token) + .await + .map(|(sats, _fed)| sats), + _ => ecash::receive_token(data_dir, token).await, + }; + match res { + Ok(sats) => tracing::info!( + "paid download: reclaimed {sats} sats of unspent {backend} ecash after a failed sale" + ), + Err(e) => tracing::warn!( + "paid download: could not reclaim {backend} ecash (the peer may have already \ + claimed it): {e:#}" + ), + } +} + impl RpcHandler { /// List content I'm sharing. pub(super) async fn handle_content_list_mine(&self) -> Result { @@ -383,35 +406,58 @@ impl RpcHandler { return Err(anyhow::anyhow!("Invalid v3 onion address")); } - // 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, + // `method` pins the backend the user confirmed in the UI ("cashu" | + // "fedimint"); absent = auto (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). + let method = params.get("method").and_then(|v| v.as_str()); + + let mint_cashu = || ecash::send_token(&self.config.data_dir, price_sats); + let mint_fedimint = + || crate::wallet::fedimint_client::spend_from_any(&self.config.data_dir, price_sats); + + let (token_str, used_backend) = match method { + Some("cashu") => match mint_cashu().await { + Ok(t) => (t, "cashu"), + Err(e) => { + tracing::warn!("paid download: cashu mint failed for {price_sats} sats: {e:#}"); + return Ok(serde_json::json!({ "error": format!( + "Couldn't pay {price_sats} sats from your Cashu wallet: {e}. \ + Fund it, or choose Fedimint." + ) })); + } + }, + Some("fedimint") => match mint_fedimint().await { + Ok((notes, fed)) => { + tracing::info!("paid download: spending {price_sats} sats Fedimint notes from {fed}"); + (notes, "fedimint") + } + Err(e) => { + tracing::warn!("paid download: fedimint spend failed for {price_sats} sats: {e:#}"); + return Ok(serde_json::json!({ "error": format!( + "Couldn't pay {price_sats} sats from your Fedimint wallet: {e}. \ + Fund it, or choose Cashu." + ) })); + } + }, + _ => match mint_cashu().await { + Ok(t) => (t, "cashu"), + Err(cashu_err) => match mint_fedimint().await { + Ok((notes, _fed)) => (notes, "fedimint"), 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." - ) - })); + 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." + ) })); } - } - } + }, + }, }; + tracing::info!("paid download: paying {price_sats} sats to {onion} via {used_backend} ecash"); let (data, _) = self.state_manager.get_snapshot().await; let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?; @@ -428,7 +474,7 @@ impl RpcHandler { ) .service(crate::settings::transport::PeerService::PeerFiles) .header("X-Federation-DID", local_did) - .header("X-Payment-Token", token_str) + .header("X-Payment-Token", token_str.clone()) .timeout(std::time::Duration::from_secs(900)) .send_get() .await @@ -436,8 +482,11 @@ impl RpcHandler { Ok(v) => v, Err(e) => { tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e); + // The token was already minted/spent — reclaim it so the buyer + // doesn't lose the value when the seller was simply unreachable. + reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await; return Ok(serde_json::json!({ - "error": "Could not reach the peer over mesh or Tor — it may be offline. Please try again." + "error": "Could not reach the peer over mesh or Tor — it may be offline. Your ecash was refunded to your wallet. Please try again." })); } }; @@ -451,15 +500,35 @@ impl RpcHandler { .await; if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED { - // Payment was rejected — token is spent but content not received + // Payment was rejected by the seller. Surface the most likely cause + // per backend — for ecash both sides must share a redemption network + // (a Cashu mint, or a Fedimint federation). + let body = response.text().await.unwrap_or_default(); + tracing::warn!( + "paid download: seller {onion} rejected {used_backend} payment of {price_sats} sats: {body}" + ); + // Seller couldn't redeem the token — reclaim it so the buyer keeps + // their funds (the spent-but-unredeemed-notes case the user hit). + reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await; + let hint = match used_backend { + "fedimint" => "the seller isn't in the same Fedimint federation as you", + _ => "the seller doesn't accept your Cashu mint", + }; return Ok(serde_json::json!({ - "error": "Payment rejected by peer — the token may have been insufficient or invalid." + "error": format!( + "Payment rejected by the seller — {hint}. Your ecash was refunded to \ + your wallet. Try the other ecash type, or use a shared mint/federation." + ) })); } if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + tracing::warn!("paid download: seller {onion} returned {status}: {body}"); + reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await; return Ok(serde_json::json!({ - "error": format!("Peer returned an error ({}).", response.status()) + "error": format!("Peer returned an error ({status}). Your ecash was refunded to your wallet.") })); } @@ -471,10 +540,12 @@ impl RpcHandler { use base64::Engine; let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); + tracing::info!("paid download: received {} bytes from {onion} (paid {price_sats} sats via {used_backend})", bytes.len()); Ok(serde_json::json!({ "data": encoded, "size": bytes.len(), "paid_sats": price_sats, + "ecash_backend": used_backend, })) } diff --git a/neode-ui/src/views/PeerFiles.vue b/neode-ui/src/views/PeerFiles.vue index 74d6e10e..278e42c6 100644 --- a/neode-ui/src/views/PeerFiles.vue +++ b/neode-ui/src/views/PeerFiles.vue @@ -287,15 +287,15 @@
@@ -344,6 +344,51 @@

{{ lnError }}

+ +
+
+
{{ getItemPrice(payItem.access) }} sats
+
from your node’s ecash wallet
+
+ + +
+ +
+ +

{{ purchaseError }}

+ +
+ + +
+
+
@@ -497,7 +542,18 @@ const audioPlayer = useAudioPlayer() // wallet (instant), or a Lightning invoice drawn on the SELLER's node that // they can pay from any external wallet by scanning a QR. const payItem = ref(null) -const payMode = ref<'choose' | 'qr'>('choose') +const payMode = ref<'choose' | 'ecash-confirm' | 'qr'>('choose') +// Ecash confirmation step: after the user picks "pay from this node's ecash", +// we look at both balances, decide which backend covers the price, and show a +// confirm screen so they see (and can switch) which ecash is spent (#3). +type EcashBackend = 'cashu' | 'fedimint' +const ecashPlan = ref<{ + cashu: number + fedimint: number + total: number + chosen: EcashBackend | null +} | null>(null) +const ecashPreparing = ref(false) // Pay-from-another-wallet QR view: tabbed like the wallet's Send/Receive modal, // on-chain first (the default). const qrTab = ref<'onchain' | 'lightning'>('onchain') @@ -733,6 +789,9 @@ function closePayModal() { if (invoicePollTimer) { clearTimeout(invoicePollTimer); invoicePollTimer = null } if (onchainPollTimer) { clearTimeout(onchainPollTimer); onchainPollTimer = null } payItem.value = null + payMode.value = 'choose' + ecashPlan.value = null + ecashPreparing.value = false invoiceWaiting.value = false onchainWaiting.value = false onchainPaying.value = false @@ -906,48 +965,75 @@ async function pollOnchain(address: string) { } } -/** Pay the open item from the local ecash wallet (mint token + download). */ -async function payWithEcash() { +/** Spendable balance for a given ecash backend in the current plan. */ +function ecashBalanceOf(b: EcashBackend): number { + if (!ecashPlan.value) return 0 + return b === 'cashu' ? ecashPlan.value.cashu : ecashPlan.value.fedimint +} + +/** + * Step 1b: look at BOTH ecash balances, pick the backend that covers the price + * (Cashu preferred, else Fedimint), and show a confirmation screen so the user + * sees exactly which wallet is spent and can switch before committing (#3). + */ +async function prepareEcashPay() { + const item = payItem.value + if (!item) return + const price = getItemPrice(item.access) + ecashPreparing.value = true + purchaseError.value = null + try { + let cashu = 0 + let fedimint = 0 + try { + const res = await rpcClient.call<{ cashu_sats?: number; fedimint_sats?: number; total_sats?: number; balance_sats?: number }>({ + method: 'wallet.ecash-balance', + }) + cashu = res?.cashu_sats ?? res?.balance_sats ?? 0 + fedimint = res?.fedimint_sats ?? 0 + } catch { + // Couldn't read balances — let the user try anyway (auto backend). + } + const total = cashu + fedimint + // Prefer Cashu when it covers the price, else Fedimint, else leave null + // (insufficient — shown in the confirm screen, Confirm disabled). + const chosen: EcashBackend | null = + cashu >= price ? 'cashu' : fedimint >= price ? 'fedimint' : null + ecashPlan.value = { cashu, fedimint, total, chosen } + if (!chosen) { + purchaseError.value = `Not enough ecash: Cashu ${cashu} + Fedimint ${fedimint} sats, need ${price}. Fund a wallet, or pay another way.` + } + payMode.value = 'ecash-confirm' + } finally { + ecashPreparing.value = false + } +} + +/** Confirm the ecash payment with the backend the user selected. */ +async function confirmEcashPay() { const item = payItem.value const onion = props.peerId || currentPeer.value?.onion - if (!item || !onion) return + const method = ecashPlan.value?.chosen + if (!item || !onion || !method) return const price = getItemPrice(item.access) downloading.value = item.id purchaseError.value = null try { - // 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<{ total_sats?: number; balance_sats?: number }>({ - method: 'wallet.ecash-balance', - }) - const balance = balanceRes?.total_sats ?? balanceRes?.balance_sats ?? 0 - if (balance < price) { - 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 - } - } catch { - // Balance check failed — try the purchase anyway - } - - const result = await rpcClient.call<{ data?: string; error?: string }>({ + const result = await rpcClient.call<{ data?: string; error?: string; ecash_backend?: string }>({ method: 'content.download-peer-paid', - params: { onion, content_id: item.id, price_sats: price }, + params: { onion, content_id: item.id, price_sats: price, method }, timeout: 120000, }) if (result?.data) { triggerDownload(result.data, item) closePayModal() } else if (result?.error) { - purchaseError.value = `Payment failed: ${result.error}` - closePayModal() + // Keep the confirm screen open so the user can switch backend and retry. + purchaseError.value = result.error } } catch (e: unknown) { purchaseError.value = e instanceof Error ? e.message : 'Download failed' - closePayModal() } finally { downloading.value = null }