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
co-authored by Claude Opus 4.8
parent 242baf5deb
commit 12f54e390d
2 changed files with 214 additions and 57 deletions
+116 -30
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
}