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:
parent
242baf5deb
commit
12f54e390d
@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@ -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 node’s 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 node’s 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 node’s 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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user