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:
co-authored by
Claude Opus 4.8
parent
242baf5deb
commit
12f54e390d
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user