feat(wallet): ecash pay confirmation screen + auto-refund on failed sale (#3)

- 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) <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-06-20 12:16:02 -04:00
parent 242baf5deb
commit 12f54e390d
2 changed files with 214 additions and 57 deletions

View File

@ -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<serde_json::Value> {
@ -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,
}))
}

View File

@ -287,15 +287,15 @@
<div v-if="payMode === 'choose'" class="space-y-3">
<button
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
:disabled="downloading === payItem.id"
@click="payWithEcash"
:disabled="ecashPreparing || downloading === payItem.id"
@click="prepareEcashPay"
>
<svg class="w-6 h-6 text-green-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
<span>
<span class="block text-base text-white">{{ downloading === payItem.id ? 'Paying…' : 'Pay from this nodes ecash wallet' }}</span>
<span class="block text-sm text-white/50">Instant, using your ecash balance</span>
<span class="block text-base text-white">{{ ecashPreparing ? 'Checking your wallets…' : 'Pay from this nodes ecash wallet' }}</span>
<span class="block text-sm text-white/50">Instant, using your Cashu or Fedimint balance</span>
</span>
</button>
@ -344,6 +344,51 @@
<p v-if="lnError" class="text-xs text-red-400 px-1">{{ lnError }}</p>
</div>
<!-- Step 1b: ecash confirmation show which wallet will be spent -->
<div v-else-if="payMode === 'ecash-confirm' && ecashPlan" class="space-y-4">
<div class="text-center py-2">
<div class="text-3xl font-bold text-white">{{ getItemPrice(payItem.access) }} <span class="text-lg text-white/50">sats</span></div>
<div class="text-xs text-white/50 mt-1">from your nodes ecash wallet</div>
</div>
<!-- Backend selector: the chosen one is highlighted; the user can
switch to the other if it has enough balance. -->
<div class="space-y-2">
<button
v-for="b in (['cashu', 'fedimint'] as const)"
:key="b"
@click="ecashPlan.chosen = b"
:disabled="ecashBalanceOf(b) < getItemPrice(payItem.access)"
class="w-full px-4 py-3 rounded-xl flex items-center gap-3 text-left border transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
:class="ecashPlan.chosen === b ? 'border-green-400/70 bg-green-400/10' : 'border-white/10 bg-white/5 hover:bg-white/10'"
>
<span class="text-xl shrink-0">{{ b === 'cashu' ? '🥜' : '🤝' }}</span>
<span class="flex-1 min-w-0">
<span class="block text-base text-white">{{ b === 'cashu' ? 'Cashu' : 'Fedimint' }}</span>
<span class="block text-xs text-white/50">Balance: {{ ecashBalanceOf(b).toLocaleString() }} sats<span v-if="ecashBalanceOf(b) < getItemPrice(payItem.access)"> · not enough</span></span>
</span>
<svg v-if="ecashPlan.chosen === b" class="w-5 h-5 text-green-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</button>
</div>
<p v-if="purchaseError" class="text-sm text-red-400">{{ purchaseError }}</p>
<div class="flex gap-2 pt-1">
<button
class="flex-1 glass-button px-4 py-2.5 rounded-xl text-sm text-white/70"
:disabled="downloading === payItem.id"
@click="payMode = 'choose'"
>Back</button>
<button
class="flex-1 px-4 py-2.5 rounded-xl text-sm font-semibold text-black bg-green-400 hover:bg-green-300 transition-colors disabled:opacity-50"
:disabled="!ecashPlan.chosen || downloading === payItem.id"
@click="confirmEcashPay"
>{{ downloading === payItem.id ? 'Paying…' : `Confirm · pay with ${ecashPlan.chosen === 'fedimint' ? 'Fedimint' : 'Cashu'}` }}</button>
</div>
</div>
<!-- Step 2: pay from another wallet tabbed QR (on-chain default) -->
<div v-else>
<!-- Method tabs, styled like the wallet Send/Receive modal -->
@ -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<CatalogItem | null>(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
}