Keep peer payment callbacks bound to their original modal operation
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { usePeerPaymentOperations } from './peerPaymentOperations'
|
||||||
|
|
||||||
|
describe('payment operation completion ownership', () => {
|
||||||
|
it('does not let a duplicated old completion clear the next operation in the same modal', () => {
|
||||||
|
const ui = usePeerPaymentOperations(() => ({ peer: 'seller', item: 'file', generation: 1 }))
|
||||||
|
const first = ui.begin('invoice', 'seller', 'file')!
|
||||||
|
expect(ui.finish(first)).toBe(true)
|
||||||
|
const second = ui.begin('invoice-status', 'seller', 'file')!
|
||||||
|
expect(ui.selected(first)).toBe(false)
|
||||||
|
expect(ui.finish(first)).toBe(false)
|
||||||
|
expect(ui.current.value?.token).toBe(second.token)
|
||||||
|
expect(ui.busy.value).toBe(true)
|
||||||
|
expect(ui.finish(second)).toBe(true)
|
||||||
|
expect(ui.busy.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { computed, reactive } from 'vue'
|
||||||
|
|
||||||
|
export type PaymentSelection = { peer: string; item: string; generation: number } | null
|
||||||
|
export type PaymentOperation = { peer: string; item: string; generation: number; kind: string; token: symbol }
|
||||||
|
|
||||||
|
/** In-flight UI ownership complements durable node/browser attempt journals.
|
||||||
|
* An old callback may persist its own receipt, but cannot own a new modal. */
|
||||||
|
export function usePeerPaymentOperations(selection: () => PaymentSelection) {
|
||||||
|
const slots = reactive(new Map<string, PaymentOperation>())
|
||||||
|
const key = (peer: string, item: string) => JSON.stringify([peer, item])
|
||||||
|
function begin(kind: string, peer: string, item: string): PaymentOperation | null {
|
||||||
|
const index = key(peer, item)
|
||||||
|
if (slots.has(index)) return null
|
||||||
|
const current = selection()
|
||||||
|
if (!current || current.peer !== peer || current.item !== item) return null
|
||||||
|
const operation = { peer, item, generation: current.generation, kind, token: Symbol(kind) }
|
||||||
|
slots.set(index, operation)
|
||||||
|
return operation
|
||||||
|
}
|
||||||
|
function selected(operation: PaymentOperation): boolean {
|
||||||
|
const current = selection()
|
||||||
|
return slots.get(key(operation.peer, operation.item))?.token === operation.token
|
||||||
|
&& current?.peer === operation.peer && current?.item === operation.item && current?.generation === operation.generation
|
||||||
|
}
|
||||||
|
function finish(operation: PaymentOperation): boolean {
|
||||||
|
const index = key(operation.peer, operation.item)
|
||||||
|
// Never reset a newer operation's busy flag, even for the same file.
|
||||||
|
const owned = slots.get(index)?.token === operation.token
|
||||||
|
const wasSelected = owned && selected(operation)
|
||||||
|
if (owned) slots.delete(index)
|
||||||
|
return wasSelected
|
||||||
|
}
|
||||||
|
const current = computed(() => {
|
||||||
|
const value = selection()
|
||||||
|
return value ? slots.get(key(value.peer, value.item)) : undefined
|
||||||
|
})
|
||||||
|
return { begin, selected, finish, current, busy: computed(() => current.value !== undefined) }
|
||||||
|
}
|
||||||
+166
-154
@@ -443,7 +443,7 @@
|
|||||||
<button
|
<button
|
||||||
v-if="acceptsMethod(payItem.access, 'onchain')"
|
v-if="acceptsMethod(payItem.access, 'onchain')"
|
||||||
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
|
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
|
||||||
:disabled="paymentActionBusy"
|
:disabled="paymentActionBusy || getItemPrice(payItem.access) < 546"
|
||||||
@click="payOnchain"
|
@click="payOnchain"
|
||||||
>
|
>
|
||||||
<svg class="w-6 h-6 text-orange-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-orange-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -492,14 +492,14 @@
|
|||||||
<div class="flex gap-2 pt-1">
|
<div class="flex gap-2 pt-1">
|
||||||
<button
|
<button
|
||||||
class="flex-1 glass-button px-4 py-2.5 rounded-xl text-sm text-white/70"
|
class="flex-1 glass-button px-4 py-2.5 rounded-xl text-sm text-white/70"
|
||||||
:disabled="downloading === payItem.id"
|
:disabled="paymentActionBusy"
|
||||||
@click="payMode = 'choose'"
|
@click="payMode = 'choose'"
|
||||||
>Back</button>
|
>Back</button>
|
||||||
<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"
|
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"
|
:disabled="!ecashPlan.chosen || paymentActionBusy"
|
||||||
@click="confirmEcashPay"
|
@click="confirmEcashPay"
|
||||||
>{{ downloading === payItem.id ? 'Paying…' : 'Pay' }}</button>
|
>{{ paymentActionBusy ? 'Paying…' : 'Pay' }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -595,6 +595,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { usePeerPaymentOperations } from '@/composables/peerPaymentOperations'
|
||||||
import { ref, computed, reactive, watch, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, reactive, watch, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import QRCode from 'qrcode'
|
import QRCode from 'qrcode'
|
||||||
@@ -816,6 +817,11 @@ async function saveOwned(item: CatalogItem) {
|
|||||||
// wallet (instant), or a Lightning invoice drawn on the SELLER's node that
|
// wallet (instant), or a Lightning invoice drawn on the SELLER's node that
|
||||||
// they can pay from any external wallet by scanning a QR.
|
// they can pay from any external wallet by scanning a QR.
|
||||||
const payItem = ref<CatalogItem | null>(null)
|
const payItem = ref<CatalogItem | null>(null)
|
||||||
|
const paymentGeneration = ref(0)
|
||||||
|
const paymentOperations = usePeerPaymentOperations(() => {
|
||||||
|
const peer = props.peerId || currentPeer.value?.onion
|
||||||
|
return payItem.value && peer ? { peer, item: payItem.value.id, generation: paymentGeneration.value } : null
|
||||||
|
})
|
||||||
const payMode = ref<'choose' | 'ecash-confirm' | 'qr'>('choose')
|
const payMode = ref<'choose' | 'ecash-confirm' | 'qr'>('choose')
|
||||||
// Ecash confirmation step: after the user picks "pay from this node's ecash",
|
// 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
|
// we look at both balances, decide which backend covers the price, and show a
|
||||||
@@ -849,7 +855,7 @@ type LightningReceipt = { bolt11: string; payment_hash: string; price_sats: numb
|
|||||||
const lnReceipt = ref<LightningReceipt | null>(null)
|
const lnReceipt = ref<LightningReceipt | null>(null)
|
||||||
const lnReceiptReadError = ref(false)
|
const lnReceiptReadError = ref(false)
|
||||||
const hasBlockingLightningReceipt = computed(() => lnReceiptReadError.value || Boolean(lnReceipt.value && lnReceipt.value.state !== 'failed'))
|
const hasBlockingLightningReceipt = computed(() => lnReceiptReadError.value || Boolean(lnReceipt.value && lnReceipt.value.state !== 'failed'))
|
||||||
const paymentActionBusy = computed(() => lnPaying.value || onchainPaying.value || ecashPreparing.value || downloading.value !== null)
|
const paymentActionBusy = computed(() => paymentOperations.busy.value || lnPaying.value || onchainPaying.value || ecashPreparing.value || downloading.value === payItem.value?.id)
|
||||||
function activePaymentMatches(onion: string, id: string) {
|
function activePaymentMatches(onion: string, id: string) {
|
||||||
return payItem.value?.id === id && (props.peerId || currentPeer.value?.onion) === onion
|
return payItem.value?.id === id && (props.peerId || currentPeer.value?.onion) === onion
|
||||||
}
|
}
|
||||||
@@ -861,17 +867,17 @@ function readReceipt(onion: string, id: string): LightningReceipt | null {
|
|||||||
if (!receipt.bolt11 || !/^[a-f0-9]{64}$/i.test(receipt.payment_hash) || (receipt.state !== undefined && !['pending', 'succeeded', 'failed'].includes(receipt.state))) throw new Error('Saved payment needs recovery. Do not pay again.')
|
if (!receipt.bolt11 || !/^[a-f0-9]{64}$/i.test(receipt.payment_hash) || (receipt.state !== undefined && !['pending', 'succeeded', 'failed'].includes(receipt.state))) throw new Error('Saved payment needs recovery. Do not pay again.')
|
||||||
return receipt
|
return receipt
|
||||||
}
|
}
|
||||||
function keepReceipt(onion: string, id: string, receipt: LightningReceipt) {
|
function keepReceipt(onion: string, id: string, receipt: LightningReceipt, selected = () => activePaymentMatches(onion, id)) {
|
||||||
// Must succeed before handing an invoice to a payer. A failed transfer or
|
// Must succeed before handing an invoice to a payer. A failed transfer or
|
||||||
// navigation must never turn Retry into a second payment.
|
// navigation must never turn Retry into a second payment.
|
||||||
localStorage.setItem(receiptKey(onion, id), JSON.stringify(receipt))
|
localStorage.setItem(receiptKey(onion, id), JSON.stringify(receipt))
|
||||||
if (activePaymentMatches(onion, id)) lnReceipt.value = receipt
|
if (selected()) lnReceipt.value = receipt
|
||||||
}
|
}
|
||||||
function keepFailedLightningAttempt(onion: string, id: string, receipt: LightningReceipt, reason: string) {
|
function keepFailedLightningAttempt(onion: string, id: string, receipt: LightningReceipt, reason: string, selected = () => activePaymentMatches(onion, id)) {
|
||||||
keepReceipt(onion, id, { ...receipt, state: 'failed', failure_reason: reason })
|
keepReceipt(onion, id, { ...receipt, state: 'failed', failure_reason: reason }, selected)
|
||||||
if (activePaymentMatches(onion, id)) lnError.value = `Lightning attempt failed: ${reason}. No sats were sent by this attempt. You can choose another payment method.`
|
if (selected()) lnError.value = `Lightning attempt failed: ${reason}. No sats were sent by this attempt. You can choose another payment method.`
|
||||||
}
|
}
|
||||||
async function recoverFailedLightningAttempt(onion: string, id: string, receipt: LightningReceipt): Promise<'failed' | 'pending' | 'other'> {
|
async function recoverFailedLightningAttempt(onion: string, id: string, receipt: LightningReceipt, selected = () => activePaymentMatches(onion, id)): Promise<'failed' | 'pending' | 'other'> {
|
||||||
// Old backends throw for terminal failures. Only LND's matching payment status
|
// Old backends throw for terminal failures. Only LND's matching payment status
|
||||||
// can distinguish that from a lost reply; never infer failure from error text.
|
// can distinguish that from a lost reply; never infer failure from error text.
|
||||||
try {
|
try {
|
||||||
@@ -879,7 +885,7 @@ async function recoverFailedLightningAttempt(onion: string, id: string, receipt:
|
|||||||
method: 'lnd.paymentstatus', params: { payment_hash: receipt.payment_hash }, timeout: 15000,
|
method: 'lnd.paymentstatus', params: { payment_hash: receipt.payment_hash }, timeout: 15000,
|
||||||
})
|
})
|
||||||
if (result?.status === 'failed') {
|
if (result?.status === 'failed') {
|
||||||
keepFailedLightningAttempt(onion, id, receipt, result.failure_reason || 'Payment failed')
|
keepFailedLightningAttempt(onion, id, receipt, result.failure_reason || 'Payment failed', selected)
|
||||||
return 'failed'
|
return 'failed'
|
||||||
}
|
}
|
||||||
if (result?.status === 'pending' || result?.status === 'in_flight') return 'pending'
|
if (result?.status === 'pending' || result?.status === 'in_flight') return 'pending'
|
||||||
@@ -952,7 +958,7 @@ function loadCatalog(): Promise<void> {
|
|||||||
// The fan-out is capped (3 concurrent) and aborts on unmount — it used to
|
// The fan-out is capped (3 concurrent) and aborts on unmount — it used to
|
||||||
// fire one 30s RPC per media item all at once, unbounded.
|
// fire one 30s RPC per media item all at once, unbounded.
|
||||||
const previewAborter = new AbortController()
|
const previewAborter = new AbortController()
|
||||||
onUnmounted(() => previewAborter.abort())
|
onUnmounted(() => { previewAborter.abort(); closePayModal() })
|
||||||
const previewQueued = new Set<string>()
|
const previewQueued = new Set<string>()
|
||||||
let previewQueue: CatalogItem[] = []
|
let previewQueue: CatalogItem[] = []
|
||||||
let previewWorkers = 0
|
let previewWorkers = 0
|
||||||
@@ -1123,6 +1129,7 @@ async function downloadFile(item: CatalogItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openPayModal(item: CatalogItem) {
|
function openPayModal(item: CatalogItem) {
|
||||||
|
paymentGeneration.value++
|
||||||
payItem.value = item
|
payItem.value = item
|
||||||
payMode.value = 'choose'
|
payMode.value = 'choose'
|
||||||
qrTab.value = 'onchain'
|
qrTab.value = 'onchain'
|
||||||
@@ -1148,6 +1155,7 @@ function openPayModal(item: CatalogItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closePayModal() {
|
function closePayModal() {
|
||||||
|
paymentGeneration.value++
|
||||||
if (invoicePollTimer) { clearTimeout(invoicePollTimer); invoicePollTimer = null }
|
if (invoicePollTimer) { clearTimeout(invoicePollTimer); invoicePollTimer = null }
|
||||||
if (onchainPollTimer) { clearTimeout(onchainPollTimer); onchainPollTimer = null }
|
if (onchainPollTimer) { clearTimeout(onchainPollTimer); onchainPollTimer = null }
|
||||||
payItem.value = null
|
payItem.value = null
|
||||||
@@ -1176,7 +1184,7 @@ function openQrPay() {
|
|||||||
onchainQr.value = ''
|
onchainQr.value = ''
|
||||||
onchainError.value = ''
|
onchainError.value = ''
|
||||||
// Start on the first tab the seller actually accepts.
|
// Start on the first tab the seller actually accepts.
|
||||||
if (payItem.value && !acceptsMethod(payItem.value.access, 'onchain')) {
|
if (payItem.value && (!acceptsMethod(payItem.value.access, 'onchain') || getItemPrice(payItem.value.access) < 546)) {
|
||||||
qrTab.value = 'lightning'
|
qrTab.value = 'lightning'
|
||||||
payWithInvoice()
|
payWithInvoice()
|
||||||
} else {
|
} else {
|
||||||
@@ -1220,46 +1228,45 @@ function selectQrTab(tab: 'onchain' | 'lightning') {
|
|||||||
* lands, then release the file (the address is the gate token).
|
* lands, then release the file (the address is the gate token).
|
||||||
*/
|
*/
|
||||||
async function loadOnchainQr() {
|
async function loadOnchainQr() {
|
||||||
|
if (hasBlockingLightningReceipt.value) return
|
||||||
const item = payItem.value
|
const item = payItem.value
|
||||||
const onion = props.peerId || currentPeer.value?.onion
|
const onion = props.peerId || currentPeer.value?.onion
|
||||||
if (!item || !onion) return
|
if (!item || !onion) return
|
||||||
|
if (getItemPrice(item.access) < 546) { onchainError.value = 'On-chain payment requires at least 546 sats. Choose Lightning or ecash for this file.'; return }
|
||||||
|
const operation = paymentOperations.begin('onchain-qr', onion, item.id)
|
||||||
|
if (!operation) return
|
||||||
onchainError.value = ''
|
onchainError.value = ''
|
||||||
onchainData.value = null
|
onchainData.value = null
|
||||||
onchainQr.value = ''
|
onchainQr.value = ''
|
||||||
onchainWaiting.value = true
|
onchainWaiting.value = true
|
||||||
try {
|
try {
|
||||||
const req = await rpcClient.call<{ address?: string; amount_sats?: number; error?: string }>({
|
const req = await rpcClient.call<{ address?: string; amount_sats?: number; error?: string }>({ method: 'content.request-onchain', params: { onion, content_id: item.id }, timeout: 45000, maxRetries: 1 })
|
||||||
method: 'content.request-onchain',
|
if (!req?.address || !req?.amount_sats) throw new Error(req?.error || 'The seller could not provide an on-chain address.')
|
||||||
params: { onion, content_id: item.id },
|
if (!Number.isSafeInteger(req.amount_sats) || req.amount_sats !== getItemPrice(item.access) || req.amount_sats < 546) throw new Error('The seller changed the payment amount. Refresh the file before paying.')
|
||||||
timeout: 45000,
|
// A closed modal has never displayed this address: do not expose a late QR.
|
||||||
})
|
if (!paymentOperations.selected(operation)) return
|
||||||
if (!req?.address || !req?.amount_sats) {
|
let image = ''
|
||||||
onchainError.value = req?.error || 'The seller could not provide an on-chain address.'
|
try { image = await QRCode.toDataURL(`bitcoin:${req.address}?amount=${(req.amount_sats / 1e8).toFixed(8)}`, { margin: 1, width: 240 }) } catch { /* raw address is available */ }
|
||||||
onchainWaiting.value = false
|
if (!paymentOperations.selected(operation)) return
|
||||||
return
|
|
||||||
}
|
|
||||||
onchainData.value = { address: req.address, amount_sats: req.amount_sats }
|
onchainData.value = { address: req.address, amount_sats: req.amount_sats }
|
||||||
const btc = (req.amount_sats / 1e8).toFixed(8)
|
onchainQr.value = image
|
||||||
try {
|
onchainPaying.value = true
|
||||||
onchainQr.value = await QRCode.toDataURL(`bitcoin:${req.address}?amount=${btc}`, { margin: 1, width: 240 })
|
void pollOnchain(req.address)
|
||||||
} catch {
|
} catch (error) {
|
||||||
onchainQr.value = '' // fall back to showing the raw address
|
if (paymentOperations.selected(operation)) onchainError.value = error instanceof Error ? error.message : 'Could not request an on-chain address'
|
||||||
}
|
} finally { if (paymentOperations.finish(operation)) onchainWaiting.value = false }
|
||||||
onchainWaiting.value = false
|
|
||||||
onchainPaying.value = true // "waiting for payment" — drives the poll loop
|
|
||||||
pollOnchain(req.address)
|
|
||||||
} catch (e: unknown) {
|
|
||||||
onchainError.value = e instanceof Error ? e.message : 'Could not request an on-chain address'
|
|
||||||
onchainWaiting.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyOnchain() {
|
async function copyOnchain() {
|
||||||
if (!onchainData.value) return
|
const original = onchainData.value
|
||||||
|
const generation = paymentGeneration.value
|
||||||
|
if (!original) return
|
||||||
|
const selected = () => generation === paymentGeneration.value && onchainData.value === original && payItem.value !== null
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(onchainData.value.address)
|
await navigator.clipboard.writeText(original.address)
|
||||||
|
if (!selected()) return
|
||||||
onchainCopied.value = true
|
onchainCopied.value = true
|
||||||
setTimeout(() => { onchainCopied.value = false }, 1500)
|
setTimeout(() => { if (selected()) onchainCopied.value = false }, 1500)
|
||||||
} catch { /* clipboard denied */ }
|
} catch { /* clipboard denied */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1274,69 +1281,55 @@ async function payOnchain() {
|
|||||||
const onion = props.peerId || currentPeer.value?.onion
|
const onion = props.peerId || currentPeer.value?.onion
|
||||||
if (!item || !onion || paymentActionBusy.value) return
|
if (!item || !onion || paymentActionBusy.value) return
|
||||||
if (hasBlockingLightningReceipt.value) { lnError.value = 'Check the saved Lightning attempt before choosing another method.'; return }
|
if (hasBlockingLightningReceipt.value) { lnError.value = 'Check the saved Lightning attempt before choosing another method.'; return }
|
||||||
|
if (getItemPrice(item.access) < 546) { lnError.value = 'On-chain payment requires at least 546 sats. Choose Lightning or ecash for this file.'; return }
|
||||||
|
const operation = paymentOperations.begin('onchain-send', onion, item.id)
|
||||||
|
if (!operation) return
|
||||||
onchainPaying.value = true
|
onchainPaying.value = true
|
||||||
lnError.value = ''
|
lnError.value = ''
|
||||||
|
let polling = false
|
||||||
try {
|
try {
|
||||||
const req = await rpcClient.call<{ address?: string; amount_sats?: number; error?: string }>({
|
const req = await rpcClient.call<{ address?: string; amount_sats?: number; error?: string }>({ method: 'content.request-onchain', params: { onion, content_id: item.id }, timeout: 45000, maxRetries: 1 })
|
||||||
method: 'content.request-onchain',
|
if (!req?.address || !req?.amount_sats) throw new Error(req?.error || 'The seller could not provide an on-chain address.')
|
||||||
params: { onion, content_id: item.id },
|
if (!Number.isSafeInteger(req.amount_sats) || req.amount_sats !== getItemPrice(item.access) || req.amount_sats < 546) throw new Error('The seller changed the payment amount. Refresh the file before paying.')
|
||||||
timeout: 45000,
|
if (!paymentOperations.selected(operation)) return
|
||||||
})
|
const send = await rpcClient.call<{ txid?: string; error?: string }>({ method: 'lnd.sendcoins', params: { addr: req.address, amount: req.amount_sats }, timeout: 60000, maxRetries: 1 })
|
||||||
if (!req?.address || !req?.amount_sats) {
|
if (!send?.txid) throw new Error(send?.error || 'The on-chain result is unconfirmed. Check this wallet transaction before sending again.')
|
||||||
lnError.value = req?.error || 'The seller could not provide an on-chain address.'
|
if (!paymentOperations.selected(operation)) return
|
||||||
onchainPaying.value = false
|
polling = true
|
||||||
return
|
void pollOnchain(req.address)
|
||||||
}
|
} catch (error) {
|
||||||
const addr = req.address
|
if (paymentOperations.selected(operation)) lnError.value = error instanceof Error ? error.message : 'Could not confirm on-chain payment'
|
||||||
const send = await rpcClient.call<{ txid?: string; error?: string }>({
|
} finally {
|
||||||
method: 'lnd.sendcoins',
|
if (paymentOperations.finish(operation) && !polling) onchainPaying.value = false
|
||||||
params: { addr, amount: req.amount_sats },
|
|
||||||
timeout: 60000, maxRetries: 1,
|
|
||||||
})
|
|
||||||
if (!send?.txid) {
|
|
||||||
lnError.value = send?.error || 'On-chain send failed (insufficient on-chain balance?).'
|
|
||||||
onchainPaying.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Broadcast — now wait for the seller to see it and release.
|
|
||||||
pollOnchain(addr)
|
|
||||||
} catch (e: unknown) {
|
|
||||||
lnError.value = e instanceof Error ? e.message : 'Could not pay on-chain'
|
|
||||||
onchainPaying.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollOnchain(address: string) {
|
type OnchainPollScope = { item: CatalogItem; onion: string; address: string; generation: number }
|
||||||
const item = payItem.value
|
function onchainScopeSelected(scope: OnchainPollScope) {
|
||||||
const onion = props.peerId || currentPeer.value?.onion
|
return paymentGeneration.value === scope.generation && activePaymentMatches(scope.onion, scope.item.id)
|
||||||
if (!item || !onion) { onchainPaying.value = false; return }
|
}
|
||||||
|
async function pollOnchain(address: string, original?: OnchainPollScope) {
|
||||||
|
const item = original?.item || payItem.value
|
||||||
|
const onion = original?.onion || props.peerId || currentPeer.value?.onion
|
||||||
|
if (!item || !onion) return
|
||||||
|
const scope = original || { item, onion, address, generation: paymentGeneration.value }
|
||||||
|
if (!onchainScopeSelected(scope)) return
|
||||||
try {
|
try {
|
||||||
const res = await rpcClient.call<{ paid?: boolean }>({
|
const res = await rpcClient.call<{ paid?: boolean }>({ method: 'content.onchain-status', params: { onion, content_id: item.id, address: scope.address }, timeout: 30000 })
|
||||||
method: 'content.onchain-status',
|
if (!onchainScopeSelected(scope)) return
|
||||||
params: { onion, content_id: item.id, address },
|
if (res?.paid === true) {
|
||||||
timeout: 30000,
|
|
||||||
})
|
|
||||||
if (res?.paid) {
|
|
||||||
const dl = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; mime_type?: string; error?: string }>({
|
const dl = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; mime_type?: string; error?: string }>({
|
||||||
method: 'content.download-peer-onchain',
|
method: 'content.download-peer-onchain', params: { onion, content_id: item.id, address: scope.address, filename: item.filename, price_sats: getItemPrice(item.access), cache_only: true }, timeout: 960000, maxRetries: 1,
|
||||||
params: { onion, content_id: item.id, address, filename: item.filename, price_sats: getItemPrice(item.access), cache_only: true },
|
|
||||||
timeout: 960000,
|
|
||||||
})
|
})
|
||||||
|
if (!onchainScopeSelected(scope)) return
|
||||||
onchainPaying.value = false
|
onchainPaying.value = false
|
||||||
if (dl?.data !== undefined || dl?.owned === true) {
|
if (dl?.data !== undefined || dl?.owned === true) {
|
||||||
openPurchased(item, dl.data, dl.mime_type, onion, dl.owned_content_id)
|
openPurchased(item, dl.data, dl.mime_type, onion, dl.owned_content_id)
|
||||||
} else {
|
} else lnError.value = dl?.error || 'Payment is confirmed; delivery is still recoverable with this address.'
|
||||||
lnError.value = dl?.error || 'Paid, but the download failed. Try again shortly.'
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} catch {
|
} catch { /* Retry read-only status for the original item only. */ }
|
||||||
// transient — keep polling
|
if (onchainScopeSelected(scope) && onchainPaying.value) onchainPollTimer = setTimeout(() => pollOnchain(scope.address, scope), 5000)
|
||||||
}
|
|
||||||
if (payItem.value && onchainPaying.value) {
|
|
||||||
onchainPollTimer = setTimeout(() => pollOnchain(address), 5000)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Spendable balance for a given ecash backend in the current plan. */
|
/** Spendable balance for a given ecash backend in the current plan. */
|
||||||
@@ -1352,8 +1345,11 @@ function ecashBalanceOf(b: EcashBackend): number {
|
|||||||
*/
|
*/
|
||||||
async function prepareEcashPay() {
|
async function prepareEcashPay() {
|
||||||
const item = payItem.value
|
const item = payItem.value
|
||||||
if (!item || paymentActionBusy.value) return
|
const onion = props.peerId || currentPeer.value?.onion
|
||||||
|
if (!item || !onion || paymentActionBusy.value) return
|
||||||
if (hasBlockingLightningReceipt.value) { lnError.value = 'Check the saved Lightning attempt before choosing another method.'; return }
|
if (hasBlockingLightningReceipt.value) { lnError.value = 'Check the saved Lightning attempt before choosing another method.'; return }
|
||||||
|
const operation = paymentOperations.begin('prepare-ecash', onion, item.id)
|
||||||
|
if (!operation) return
|
||||||
const price = getItemPrice(item.access)
|
const price = getItemPrice(item.access)
|
||||||
ecashPreparing.value = true
|
ecashPreparing.value = true
|
||||||
purchaseError.value = null
|
purchaseError.value = null
|
||||||
@@ -1371,6 +1367,7 @@ async function prepareEcashPay() {
|
|||||||
} catch {
|
} catch {
|
||||||
// Couldn't read balances — let the user try anyway (auto backend).
|
// Couldn't read balances — let the user try anyway (auto backend).
|
||||||
}
|
}
|
||||||
|
if (!paymentOperations.selected(operation)) return
|
||||||
const total = cashu + fedimint + ark
|
const total = cashu + fedimint + ark
|
||||||
// Prefer Cashu when it covers the price, else Fedimint, else Ark, else
|
// Prefer Cashu when it covers the price, else Fedimint, else Ark, else
|
||||||
// leave null (insufficient — shown in the confirm screen, Confirm disabled).
|
// leave null (insufficient — shown in the confirm screen, Confirm disabled).
|
||||||
@@ -1382,7 +1379,7 @@ async function prepareEcashPay() {
|
|||||||
}
|
}
|
||||||
payMode.value = 'ecash-confirm'
|
payMode.value = 'ecash-confirm'
|
||||||
} finally {
|
} finally {
|
||||||
ecashPreparing.value = false
|
if (paymentOperations.finish(operation)) ecashPreparing.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1429,9 +1426,11 @@ async function confirmEcashPay() {
|
|||||||
const onion = props.peerId || currentPeer.value?.onion
|
const onion = props.peerId || currentPeer.value?.onion
|
||||||
const method = ecashPlan.value?.chosen
|
const method = ecashPlan.value?.chosen
|
||||||
if (!item || !onion || !method || paymentActionBusy.value || hasBlockingLightningReceipt.value) return
|
if (!item || !onion || !method || paymentActionBusy.value || hasBlockingLightningReceipt.value) return
|
||||||
|
const operation = paymentOperations.begin('ecash-send', onion, item.id)
|
||||||
|
if (!operation) return
|
||||||
|
const selected = () => paymentOperations.selected(operation)
|
||||||
const price = getItemPrice(item.access)
|
const price = getItemPrice(item.access)
|
||||||
|
|
||||||
downloading.value = item.id
|
|
||||||
purchaseError.value = null
|
purchaseError.value = null
|
||||||
try {
|
try {
|
||||||
const result = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; error?: string; ecash_backend?: string; mime_type?: string }>({
|
const result = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; error?: string; ecash_backend?: string; mime_type?: string }>({
|
||||||
@@ -1441,15 +1440,15 @@ async function confirmEcashPay() {
|
|||||||
})
|
})
|
||||||
if (result?.data !== undefined || result?.owned === true) {
|
if (result?.data !== undefined || result?.owned === true) {
|
||||||
// The purchase is now cached + owned by this node (backend persisted it).
|
// The purchase is now cached + owned by this node (backend persisted it).
|
||||||
openPurchased(item, result.data, result.mime_type, onion, result.owned_content_id)
|
if (selected()) openPurchased(item, result.data, result.mime_type, onion, result.owned_content_id)
|
||||||
} else if (result?.error) {
|
} else if (selected() && result?.error) {
|
||||||
// Keep the confirm screen open so the user can switch backend and retry.
|
// Preserve the backend's recovery explanation; an error is not proof of nonpayment.
|
||||||
purchaseError.value = result.error
|
purchaseError.value = result.error
|
||||||
}
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
purchaseError.value = e instanceof Error ? e.message : 'Download failed'
|
if (selected()) purchaseError.value = e instanceof Error ? e.message : 'Download failed'
|
||||||
} finally {
|
} finally {
|
||||||
downloading.value = null
|
paymentOperations.finish(operation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1458,33 +1457,32 @@ async function payWithInvoice() {
|
|||||||
const item = payItem.value
|
const item = payItem.value
|
||||||
const onion = props.peerId || currentPeer.value?.onion
|
const onion = props.peerId || currentPeer.value?.onion
|
||||||
if (!item || !onion) return
|
if (!item || !onion) return
|
||||||
|
const operation = paymentOperations.begin('invoice', onion, item.id)
|
||||||
|
if (!operation) return
|
||||||
payMode.value = 'qr'
|
payMode.value = 'qr'
|
||||||
invoiceError.value = ''
|
invoiceError.value = ''
|
||||||
invoiceWaiting.value = true
|
invoiceWaiting.value = true
|
||||||
try {
|
try {
|
||||||
const saved = readReceipt(onion, item.id)
|
const saved = readReceipt(onion, item.id)
|
||||||
const res = (saved?.state === 'failed' ? null : saved) as (LightningReceipt & { error?: string }) | null || await rpcClient.call<{ bolt11?: string; payment_hash?: string; price_sats?: number; error?: string }>({
|
const res = (saved?.state === 'failed' ? null : saved) as (LightningReceipt & { error?: string }) | null || await rpcClient.call<{ bolt11?: string; payment_hash?: string; price_sats?: number; error?: string }>({
|
||||||
method: 'content.request-invoice',
|
method: 'content.request-invoice', params: { onion, content_id: item.id }, timeout: 45000, maxRetries: 1,
|
||||||
params: { onion, content_id: item.id },
|
|
||||||
timeout: 45000,
|
|
||||||
})
|
})
|
||||||
if (!res?.bolt11 || !res?.payment_hash) {
|
if (!res?.bolt11 || !res?.payment_hash) throw new Error(res?.error || 'The seller could not create an invoice.')
|
||||||
invoiceError.value = res?.error || 'The seller could not create an invoice (is its Lightning node running?).'
|
const receipt: LightningReceipt = { bolt11: res.bolt11, payment_hash: res.payment_hash, price_sats: res.price_sats ?? getItemPrice(item.access), state: 'pending' }
|
||||||
invoiceWaiting.value = false
|
// Preserve this request even if the modal closed; never expose it in a new modal.
|
||||||
return
|
localStorage.setItem(receiptKey(onion, item.id), JSON.stringify(receipt))
|
||||||
}
|
if (!paymentOperations.selected(operation)) return
|
||||||
invoiceData.value = { bolt11: res.bolt11, payment_hash: res.payment_hash, price_sats: res.price_sats ?? getItemPrice(item.access) }
|
lnReceipt.value = receipt
|
||||||
keepReceipt(onion, item.id, { ...invoiceData.value, state: 'pending' })
|
invoiceData.value = receipt
|
||||||
try {
|
let image = ''
|
||||||
invoiceQr.value = await QRCode.toDataURL(res.bolt11.toUpperCase(), { margin: 1, width: 240 })
|
try { image = await QRCode.toDataURL(res.bolt11.toUpperCase(), { margin: 1, width: 240 }) } catch { /* raw invoice remains usable */ }
|
||||||
} catch {
|
if (!paymentOperations.selected(operation)) return
|
||||||
invoiceQr.value = '' // fall back to showing the raw invoice string
|
invoiceQr.value = image
|
||||||
}
|
|
||||||
scheduleInvoicePoll()
|
scheduleInvoicePoll()
|
||||||
} catch (e: unknown) {
|
} catch (error) {
|
||||||
invoiceError.value = e instanceof Error ? e.message : 'Could not request an invoice'
|
if (paymentOperations.selected(operation)) invoiceError.value = error instanceof Error ? error.message : 'Could not request an invoice'
|
||||||
invoiceWaiting.value = false
|
} finally {
|
||||||
|
if (paymentOperations.finish(operation)) invoiceWaiting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1497,96 +1495,110 @@ async function payWithLightning() {
|
|||||||
const item = payItem.value
|
const item = payItem.value
|
||||||
const onion = props.peerId || currentPeer.value?.onion
|
const onion = props.peerId || currentPeer.value?.onion
|
||||||
if (!item || !onion || paymentActionBusy.value || lnReceiptReadError.value) return
|
if (!item || !onion || paymentActionBusy.value || lnReceiptReadError.value) return
|
||||||
|
const operation = paymentOperations.begin('lightning', onion, item.id)
|
||||||
|
if (!operation) return
|
||||||
|
const selected = () => paymentOperations.selected(operation)
|
||||||
lnPaying.value = true
|
lnPaying.value = true
|
||||||
lnError.value = ''
|
lnError.value = ''
|
||||||
let inv: LightningReceipt | null = null
|
let inv: LightningReceipt | null = null
|
||||||
try {
|
try {
|
||||||
inv = readReceipt(onion, item.id)
|
inv = readReceipt(onion, item.id)
|
||||||
if (inv && inv.state !== 'failed') {
|
if (inv && inv.state !== 'failed') {
|
||||||
const state = await recoverFailedLightningAttempt(onion, item.id, inv)
|
const state = await recoverFailedLightningAttempt(onion, item.id, inv, selected)
|
||||||
if (state === 'failed') return
|
if (state === 'failed') return
|
||||||
if (state === 'pending') {
|
if (state === 'pending') {
|
||||||
if (activePaymentMatches(onion, item.id)) lnError.value = 'Payment is still settling. You can close this window; check this saved attempt later without sending more sats.'
|
if (selected()) lnError.value = 'Payment is still settling. You can close this window; check this saved attempt later without sending more sats.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!selected()) return
|
||||||
if (!inv || inv.state === 'failed') {
|
if (!inv || inv.state === 'failed') {
|
||||||
const result = await rpcClient.call<{ bolt11?: string; payment_hash?: string; error?: string }>({
|
const result = await rpcClient.call<{ bolt11?: string; payment_hash?: string; error?: string }>({
|
||||||
method: 'content.request-invoice', params: { onion, content_id: item.id }, timeout: 45000,
|
method: 'content.request-invoice', params: { onion, content_id: item.id }, timeout: 45000, maxRetries: 1,
|
||||||
})
|
})
|
||||||
if (!result?.bolt11 || !result.payment_hash) throw new Error(result?.error || 'The seller could not create an invoice.')
|
if (!result?.bolt11 || !result.payment_hash) throw new Error(result?.error || 'The seller could not create an invoice.')
|
||||||
|
if (!selected()) return
|
||||||
inv = { bolt11: result.bolt11, payment_hash: result.payment_hash, price_sats: getItemPrice(item.access), state: 'pending' }
|
inv = { bolt11: result.bolt11, payment_hash: result.payment_hash, price_sats: getItemPrice(item.access), state: 'pending' }
|
||||||
keepReceipt(onion, item.id, inv)
|
keepReceipt(onion, item.id, inv, selected)
|
||||||
const pay = await rpcClient.payLightningInvoice({ payment_request: inv.bolt11 })
|
const pay = await rpcClient.payLightningInvoice({ payment_request: inv.bolt11 })
|
||||||
if (pay.status === 'failed') {
|
if (pay.status === 'failed') {
|
||||||
keepFailedLightningAttempt(onion, item.id, inv, pay.failure_reason || 'Payment failed')
|
keepFailedLightningAttempt(onion, item.id, inv, pay.failure_reason || 'Payment failed', selected)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (pay.status !== 'succeeded') {
|
if (pay.status !== 'succeeded') {
|
||||||
if (activePaymentMatches(onion, item.id)) lnError.value = 'Payment is still settling. Retry checks this payment without sending more sats.'
|
if (selected()) lnError.value = 'Payment is still settling. Retry checks this payment without sending more sats.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
inv = { ...inv, state: 'succeeded' }
|
inv = { ...inv, state: 'succeeded' }
|
||||||
keepReceipt(onion, item.id, inv)
|
keepReceipt(onion, item.id, inv, selected)
|
||||||
}
|
}
|
||||||
const dl = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; mime_type?: string; error?: string }>({
|
const dl = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; mime_type?: string; error?: string }>({
|
||||||
method: 'content.download-peer-invoice',
|
method: 'content.download-peer-invoice',
|
||||||
params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true },
|
params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true },
|
||||||
timeout: 960000,
|
timeout: 960000,
|
||||||
})
|
})
|
||||||
if (!activePaymentMatches(onion, item.id)) return
|
if (!selected()) return
|
||||||
if (dl?.data !== undefined || dl?.owned === true) openPurchased(item, dl.data, dl.mime_type, onion)
|
if (dl?.data !== undefined || dl?.owned === true) openPurchased(item, dl.data, dl.mime_type, onion)
|
||||||
else lnError.value = dl?.error || 'Download unavailable. Retry uses this payment without sending more sats.'
|
else lnError.value = dl?.error || 'Download unavailable. Retry uses this payment without sending more sats.'
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (inv && inv.state !== 'failed' && inv.state !== 'succeeded' && await recoverFailedLightningAttempt(onion, item.id, inv) === 'failed') return
|
if (inv && inv.state !== 'failed' && inv.state !== 'succeeded' && await recoverFailedLightningAttempt(onion, item.id, inv, selected) === 'failed') return
|
||||||
if (activePaymentMatches(onion, item.id)) lnError.value = (e instanceof Error ? e.message : 'Payment or download could not be confirmed') + ' Retry checks the saved payment; do not pay again.'
|
if (selected()) lnError.value = (e instanceof Error ? e.message : 'Payment or download could not be confirmed') + ' Retry checks the saved payment; do not pay again.'
|
||||||
} finally { if (activePaymentMatches(onion, item.id)) lnPaying.value = false }
|
} finally { if (paymentOperations.finish(operation)) lnPaying.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleInvoicePoll() {
|
type InvoicePollScope = { item: CatalogItem; onion: string; invoice: LightningReceipt; generation: number }
|
||||||
if (invoicePollTimer) clearTimeout(invoicePollTimer)
|
function invoiceScopeSelected(scope: InvoicePollScope) {
|
||||||
invoicePollTimer = setTimeout(pollInvoice, 1000)
|
return paymentGeneration.value === scope.generation && activePaymentMatches(scope.onion, scope.item.id)
|
||||||
|
&& invoiceData.value?.payment_hash === scope.invoice.payment_hash
|
||||||
}
|
}
|
||||||
|
function scheduleInvoicePoll(scope?: InvoicePollScope) {
|
||||||
async function pollInvoice() {
|
if (!scope) {
|
||||||
const item = payItem.value
|
const item = payItem.value
|
||||||
const inv = invoiceData.value
|
|
||||||
const onion = props.peerId || currentPeer.value?.onion
|
const onion = props.peerId || currentPeer.value?.onion
|
||||||
if (!item || !inv || !onion) return
|
const invoice = invoiceData.value
|
||||||
|
if (!item || !onion || !invoice) return
|
||||||
|
scope = { item, onion, invoice, generation: paymentGeneration.value }
|
||||||
|
}
|
||||||
|
if (!invoiceScopeSelected(scope)) return
|
||||||
|
if (invoicePollTimer) clearTimeout(invoicePollTimer)
|
||||||
|
const original = scope
|
||||||
|
invoicePollTimer = setTimeout(() => pollInvoice(original), 1000)
|
||||||
|
}
|
||||||
|
async function pollInvoice(scope: InvoicePollScope) {
|
||||||
|
if (!invoiceScopeSelected(scope)) return
|
||||||
|
const { item, onion, invoice: inv } = scope
|
||||||
try {
|
try {
|
||||||
const res = await rpcClient.call<{ paid?: boolean }>({
|
const res = await rpcClient.call<{ paid?: boolean }>({
|
||||||
method: 'content.invoice-status',
|
method: 'content.invoice-status',
|
||||||
params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true },
|
params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true }, timeout: 30000,
|
||||||
timeout: 30000,
|
|
||||||
})
|
})
|
||||||
if (res?.paid) {
|
if (!invoiceScopeSelected(scope)) return
|
||||||
// Settled — pull the file using the payment hash as the gate token.
|
if (res?.paid === true) {
|
||||||
invoiceWaiting.value = false
|
localStorage.setItem(receiptKey(onion, item.id), JSON.stringify({ ...inv, state: 'succeeded' }))
|
||||||
const dl = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; mime_type?: string; error?: string }>({
|
const dl = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; mime_type?: string; error?: string }>({
|
||||||
method: 'content.download-peer-invoice',
|
method: 'content.download-peer-invoice',
|
||||||
params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true },
|
params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true }, timeout: 960000, maxRetries: 1,
|
||||||
timeout: 960000,
|
|
||||||
})
|
})
|
||||||
if (dl?.data !== undefined || dl?.owned === true) {
|
if (!invoiceScopeSelected(scope)) return
|
||||||
openPurchased(item, dl.data, dl.mime_type, onion)
|
if (dl?.data !== undefined || dl?.owned === true) openPurchased(item, dl.data, dl.mime_type, onion, dl.owned_content_id)
|
||||||
} else {
|
else invoiceError.value = dl?.error || 'Payment is confirmed; delivery is still recoverable with this invoice.'
|
||||||
invoiceError.value = dl?.error || 'Paid, but the download failed. Try again shortly.'
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} catch {
|
} catch { /* A transient status failure never permits a new payment. */ }
|
||||||
// Transient — keep polling.
|
if (invoiceScopeSelected(scope)) scheduleInvoicePoll(scope)
|
||||||
}
|
|
||||||
if (payItem.value && invoiceData.value) scheduleInvoicePoll()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyInvoice() {
|
async function copyInvoice() {
|
||||||
if (!invoiceData.value) return
|
const original = invoiceData.value
|
||||||
|
const generation = paymentGeneration.value
|
||||||
|
if (!original) return
|
||||||
|
const selected = () => generation === paymentGeneration.value && invoiceData.value === original && payItem.value !== null
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(invoiceData.value.bolt11)
|
await navigator.clipboard.writeText(original.bolt11)
|
||||||
|
if (!selected()) return
|
||||||
invoiceCopied.value = true
|
invoiceCopied.value = true
|
||||||
setTimeout(() => { invoiceCopied.value = false }, 2000)
|
setTimeout(() => { if (selected()) invoiceCopied.value = false }, 2000)
|
||||||
} catch { /* clipboard blocked */ }
|
} catch { /* clipboard denied */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Play audio/video inline. For free items, downloads full file; for paid, uses the preview. */
|
/** Play audio/video inline. For free items, downloads full file; for paid, uses the preview. */
|
||||||
|
|||||||
@@ -210,4 +210,197 @@ describe('Lightning file delivery recovery', () => {
|
|||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Append inside the existing PeerFilesLightning describe; uses real mounted component.
|
||||||
|
it('does not pay when an invoice arrives after closing and reopening the same file', async () => {
|
||||||
|
let reply!: (value: unknown) => void
|
||||||
|
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'content.request-invoice'
|
||||||
|
? await new Promise(resolve => { reply = resolve }) : { items: [] })
|
||||||
|
const { wrapper, vm } = await open()
|
||||||
|
const pending = vm.payWithLightning()
|
||||||
|
vm.closePayModal(); vm.openPayModal(item)
|
||||||
|
reply({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5 })
|
||||||
|
await pending
|
||||||
|
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
|
||||||
|
expect(vm.payItem.id).toBe(item.id)
|
||||||
|
expect(vm.lnPaying).toBe(false)
|
||||||
|
expect(localStorage.getItem(receiptKey)).toBeNull()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('retains a late invoice for its original file without changing another file modal', async () => {
|
||||||
|
let reply!: (value: unknown) => void
|
||||||
|
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'content.request-invoice'
|
||||||
|
? await new Promise(resolve => { reply = resolve }) : { items: [] })
|
||||||
|
const { wrapper, vm } = await open()
|
||||||
|
const pending = vm.payWithInvoice()
|
||||||
|
await vm.payWithInvoice()
|
||||||
|
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.request-invoice')).toHaveLength(1)
|
||||||
|
vm.closePayModal(); vm.openPayModal({ ...item, id: 'second-file' })
|
||||||
|
reply({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5 })
|
||||||
|
await pending
|
||||||
|
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ payment_hash: hash })
|
||||||
|
expect(vm.payItem.id).toBe('second-file')
|
||||||
|
expect(vm.invoiceData).toBeNull()
|
||||||
|
expect(vm.lnReceipt).toBeNull()
|
||||||
|
expect(vm.invoiceWaiting).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('keeps a new file balance preparation busy when an older preparation finishes', async () => {
|
||||||
|
const replies: ((value: unknown) => void)[] = []
|
||||||
|
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'wallet.ecash-balance'
|
||||||
|
? await new Promise(resolve => { replies.push(resolve) }) : { items: [] })
|
||||||
|
const { wrapper, vm } = await open()
|
||||||
|
const first = vm.prepareEcashPay()
|
||||||
|
vm.closePayModal(); vm.openPayModal({ ...item, id: 'second-file' })
|
||||||
|
const second = vm.prepareEcashPay()
|
||||||
|
replies[0]!({ cashu_sats: 100 })
|
||||||
|
await first
|
||||||
|
expect(vm.ecashPreparing).toBe(true)
|
||||||
|
expect(vm.ecashPlan).toBeNull()
|
||||||
|
replies[1]!({ cashu_sats: 20 })
|
||||||
|
await second
|
||||||
|
expect(vm.ecashPreparing).toBe(false)
|
||||||
|
expect(vm.ecashPlan.cashu).toBe(20)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('cannot deliver a late paid invoice into another file modal', async () => {
|
||||||
|
let reply!: (value: unknown) => void
|
||||||
|
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||||
|
if (method === 'content.invoice-status') return { paid: true }
|
||||||
|
if (method === 'content.download-peer-invoice') return await new Promise(resolve => { reply = resolve })
|
||||||
|
return { items: [] }
|
||||||
|
})
|
||||||
|
const { wrapper, vm } = await open()
|
||||||
|
const invoice = { bolt11: 'ln-test', payment_hash: hash, price_sats: 5 }
|
||||||
|
vm.invoiceData = invoice
|
||||||
|
const pending = vm.pollInvoice({ item, onion: 'peer.onion', invoice, generation: vm.paymentGeneration })
|
||||||
|
await flushPromises()
|
||||||
|
vm.closePayModal(); vm.openPayModal({ ...item, id: 'second-file' })
|
||||||
|
reply({ owned: true, mime_type: 'video/mp4' })
|
||||||
|
await pending
|
||||||
|
expect(vm.viewerUrl).toBeNull()
|
||||||
|
expect(vm.payItem.id).toBe('second-file')
|
||||||
|
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ state: 'succeeded' })
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('persists a dispatched Lightning result after closing without changing another file', async () => {
|
||||||
|
let reply!: (value: unknown) => void
|
||||||
|
vi.mocked(rpcClient.payLightningInvoice).mockImplementation(async () => await new Promise<unknown>(resolve => { reply = resolve }) as never)
|
||||||
|
download.mockResolvedValue({ error: 'Delivery remains recoverable' })
|
||||||
|
const { wrapper, vm } = await open()
|
||||||
|
const pending = vm.payWithLightning()
|
||||||
|
await flushPromises()
|
||||||
|
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ state: 'pending' })
|
||||||
|
vm.closePayModal(); vm.openPayModal({ ...item, id: 'second-file' })
|
||||||
|
reply({ status: 'succeeded' })
|
||||||
|
await pending
|
||||||
|
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ state: 'succeeded' })
|
||||||
|
expect(vm.payItem.id).toBe('second-file')
|
||||||
|
expect(vm.viewerUrl).toBeNull()
|
||||||
|
expect(vm.lnReceipt).toBeNull()
|
||||||
|
expect(vm.lnError).toBe('')
|
||||||
|
expect(rpcClient.payLightningInvoice).toHaveBeenCalledTimes(1)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('does not dispatch Lightning when its invoice arrives after component unmount', async () => {
|
||||||
|
let reply!: (value: unknown) => void
|
||||||
|
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'content.request-invoice'
|
||||||
|
? await new Promise(resolve => { reply = resolve }) : { items: [] })
|
||||||
|
const { wrapper, vm } = await open()
|
||||||
|
const pending = vm.payWithLightning()
|
||||||
|
wrapper.unmount()
|
||||||
|
reply({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5 })
|
||||||
|
await pending
|
||||||
|
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
|
||||||
|
expect(localStorage.getItem(receiptKey)).toBeNull()
|
||||||
|
})
|
||||||
|
it('retains already dispatched Lightning evidence after unmount without opening playback', async () => {
|
||||||
|
let reply!: (value: unknown) => void
|
||||||
|
vi.mocked(rpcClient.payLightningInvoice).mockImplementation(async () => await new Promise<unknown>(resolve => { reply = resolve }) as never)
|
||||||
|
download.mockResolvedValue({ owned: true, mime_type: 'video/mp4' })
|
||||||
|
const { wrapper, vm } = await open()
|
||||||
|
const pending = vm.payWithLightning()
|
||||||
|
await flushPromises()
|
||||||
|
wrapper.unmount()
|
||||||
|
reply({ status: 'succeeded' })
|
||||||
|
await pending
|
||||||
|
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ state: 'succeeded' })
|
||||||
|
expect(vm.viewerUrl).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not create an address, attempt or onchain send for a 545-sat file', async () => {
|
||||||
|
const { wrapper, vm } = await open()
|
||||||
|
vm.openPayModal({ ...item, access: { paid: { price_sats: 545, accepted: ['onchain', 'lightning', 'ecash'] } } })
|
||||||
|
await vm.payOnchain()
|
||||||
|
await vm.loadOnchainQr()
|
||||||
|
expect(vi.mocked(rpcClient.call).mock.calls.some(([call]) => ['content.request-onchain', 'lnd.sendcoins'].includes(call.method))).toBe(false)
|
||||||
|
expect(vm.paymentActionBusy).toBe(false)
|
||||||
|
expect(vm.hasBlockingLightningReceipt).toBe(false)
|
||||||
|
expect(vm.lnError).toContain('546')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('allows the exact 546-sat boundary and never dispatches a changed seller amount', async () => {
|
||||||
|
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||||
|
if (method === 'content.request-onchain') return { address: 'bc1test', amount_sats: 547 }
|
||||||
|
return { items: [] }
|
||||||
|
})
|
||||||
|
const { wrapper, vm } = await open()
|
||||||
|
vm.openPayModal({ ...item, access: { paid: { price_sats: 546, accepted: ['onchain', 'lightning', 'ecash'] } } })
|
||||||
|
await vm.payOnchain()
|
||||||
|
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.request-onchain')).toHaveLength(1)
|
||||||
|
expect(vi.mocked(rpcClient.call).mock.calls.some(([v]) => v.method === 'lnd.sendcoins')).toBe(false)
|
||||||
|
expect(vm.lnError).toContain('changed the payment amount')
|
||||||
|
expect(vm.paymentActionBusy).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['copyInvoice', 'invoiceData', 'invoiceCopied', { bolt11: 'invoice-one', payment_hash: hash, price_sats: 5 }],
|
||||||
|
['copyOnchain', 'onchainData', 'onchainCopied', { address: 'address-one', amount_sats: 546 }],
|
||||||
|
] as const)('ignores late %s completion after reopening the same file', async (method, data, copied, value) => {
|
||||||
|
let resolve!: () => void
|
||||||
|
const writeText = vi.fn(() => new Promise<void>(done => { resolve = done }))
|
||||||
|
const previous = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
|
||||||
|
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||||
|
const { wrapper, vm } = await open()
|
||||||
|
try {
|
||||||
|
vm[data] = value
|
||||||
|
const pending = vm[method]()
|
||||||
|
vm.closePayModal(); vm.openPayModal(item)
|
||||||
|
vm[data] = { ...value }
|
||||||
|
resolve(); await pending
|
||||||
|
expect(vm[copied]).toBe(false)
|
||||||
|
expect(writeText).toHaveBeenCalledTimes(1)
|
||||||
|
} finally {
|
||||||
|
wrapper.unmount()
|
||||||
|
if (previous) Object.defineProperty(navigator, 'clipboard', previous)
|
||||||
|
else Reflect.deleteProperty(navigator, 'clipboard')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
it.each([
|
||||||
|
['copyInvoice', 'invoiceData', 'invoiceCopied', { bolt11: 'invoice-one', payment_hash: hash, price_sats: 5 }],
|
||||||
|
['copyOnchain', 'onchainData', 'onchainCopied', { address: 'address-one', amount_sats: 546 }],
|
||||||
|
] as const)('does not let the old %s timer reset a reopened modal', async (method, data, copied, value) => {
|
||||||
|
const previous = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
|
||||||
|
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: vi.fn().mockResolvedValue(undefined) } })
|
||||||
|
const { wrapper, vm } = await open()
|
||||||
|
vi.useFakeTimers()
|
||||||
|
try {
|
||||||
|
vm[data] = value
|
||||||
|
await vm[method]()
|
||||||
|
expect(vm[copied]).toBe(true)
|
||||||
|
await vi.advanceTimersByTimeAsync(1000)
|
||||||
|
vm.closePayModal(); vm.openPayModal(item)
|
||||||
|
vm[data] = { ...value }
|
||||||
|
await vm[method]()
|
||||||
|
await vi.advanceTimersByTimeAsync(1000)
|
||||||
|
expect(vm[copied]).toBe(true)
|
||||||
|
await vi.advanceTimersByTimeAsync(1000)
|
||||||
|
expect(vm[copied]).toBe(false)
|
||||||
|
} finally {
|
||||||
|
wrapper.unmount(); vi.useRealTimers()
|
||||||
|
if (previous) Object.defineProperty(navigator, 'clipboard', previous)
|
||||||
|
else Reflect.deleteProperty(navigator, 'clipboard')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user