Integrate recoverable native purchases, registered rentals and explicit payment consent
This commit is contained in:
@@ -91,6 +91,10 @@
|
||||
|
||||
<!-- Host-owned signer stays inside the active app surface. This keeps
|
||||
the context visible and works unchanged in the companion WebView. -->
|
||||
<RentalPurchaseConsent v-if="!nostrBridge.showConsent.value && !showIdentityPicker && !registrationBridge.request.value" :request="rentalBridge.request.value" :quote="rentalBridge.quote.value" :phase="rentalBridge.phase.value" :error="rentalBridge.error.value" @cancel-unpaid="rentalBridge.cancelUnpaid" @review="rentalBridge.review" @approve="rentalBridge.approve" @cancel="rentalBridge.cancel" />
|
||||
<MediaRegistrationConsent v-if="!nostrBridge.showConsent.value"
|
||||
:request="registrationBridge.request.value" :phase="registrationBridge.phase.value"
|
||||
:error="registrationBridge.error.value" @approve="registrationBridge.approve" @resolve="registrationBridge.approveResolution" @cancel="registrationBridge.cancel" />
|
||||
<NostrSignConsent
|
||||
:show="nostrBridge.showConsent.value"
|
||||
:app-name="nostrBridge.consentRequest.value?.appName ?? appTitle"
|
||||
@@ -123,6 +127,10 @@ import { useAppStore } from '@/stores/app'
|
||||
import { useScreensaverStore } from '@/stores/screensaver'
|
||||
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
|
||||
import NostrSignConsent from '@/components/NostrSignConsent.vue'
|
||||
import MediaRegistrationConsent from '@/components/MediaRegistrationConsent.vue'
|
||||
import RentalPurchaseConsent from '@/components/RentalPurchaseConsent.vue'
|
||||
import { useMediaRegistrationBridge } from '@/composables/useMediaRegistrationBridge'
|
||||
import { useRentalPurchaseBridge } from '@/composables/useRentalPurchaseBridge'
|
||||
import { isAutoTabApp, rememberAutoTabApp, forgetAutoTabApp } from '@/utils/autoTabApps'
|
||||
import AppSessionHeader from './appSession/AppSessionHeader.vue'
|
||||
import AppSessionFrame from './appSession/AppSessionFrame.vue'
|
||||
@@ -293,20 +301,33 @@ function closeRouteSession() {
|
||||
const iframeRef = computed(() => frameRef.value?.iframeRef ?? null)
|
||||
const mediaBridge = useAppMediaBridge(appId, appUrl, iframeRef, computed(() => !props.suspended))
|
||||
|
||||
const registrationBridge = useMediaRegistrationBridge({
|
||||
consentBusy: (): boolean => rentalBridge.isBusy(),
|
||||
appId: () => appId.value, appUrl: () => appUrl.value,
|
||||
frameWindow: () => props.suspended ? null : iframeRef.value?.contentWindow ?? null,
|
||||
})
|
||||
const rentalBridge = useRentalPurchaseBridge({
|
||||
consentBusy: (): boolean => nostrBridge.showConsent.value || showIdentityPicker.value || registrationBridge.isBusy(),
|
||||
appId: () => appId.value, appUrl: () => appUrl.value,
|
||||
frameWindow: () => props.suspended ? null : iframeRef.value?.contentWindow ?? null,
|
||||
})
|
||||
const identity = useAppIdentity(appId, iframeRef, showIdentityPicker)
|
||||
const nostrBridge = useNostrBridge(identity.getStoredIdentity, {
|
||||
appId: () => appId.value,
|
||||
appName: () => appTitle.value,
|
||||
appUrl: () => appUrl.value,
|
||||
frameWindow: () => iframeRef.value?.contentWindow ?? null,
|
||||
frameWindow: () => props.suspended ? null : iframeRef.value?.contentWindow ?? null,
|
||||
})
|
||||
|
||||
// An actual destination change invalidates consent queued for the previous app page.
|
||||
watch(() => props.suspended, suspended => { if (suspended) { nostrBridge.cancelPending(); registrationBridge.cancel(); rentalBridge.cancel() } }, { flush: 'sync' })
|
||||
|
||||
watch(appUrl, () => {
|
||||
loadedAppUrl.value = ''
|
||||
slowLoad.value = false
|
||||
nostrBridge.cancelPending()
|
||||
})
|
||||
registrationBridge.cancel(); rentalBridge.cancel()
|
||||
}, { flush: 'sync' })
|
||||
|
||||
// --- Display mode ---
|
||||
|
||||
@@ -514,6 +535,7 @@ function handleBackdropClick() {
|
||||
|
||||
function closeSession() {
|
||||
nostrBridge.cancelPending()
|
||||
registrationBridge.cancel(); rentalBridge.cancel()
|
||||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
||||
if (isInlinePanel.value) emit('close')
|
||||
else closeRouteSession()
|
||||
@@ -543,6 +565,8 @@ function onFullscreenChange() {
|
||||
function onMessage(e: MessageEvent) {
|
||||
if (e.source !== iframeRef.value?.contentWindow) return
|
||||
mediaBridge.handle(e)
|
||||
if (props.suspended) return
|
||||
void registrationBridge.handle(e); void rentalBridge.handle(e)
|
||||
if (e.data?.type === 'nostr-request') nostrBridge.handleNostrRequest(e)
|
||||
if (e.data?.type === 'archipelago:identity:request') identity.handleIdentityRequest(e.data?.force === true)
|
||||
if (e.data?.type === 'archipelago:media:playing') screensaverStore.suppress(screensaverReason.value)
|
||||
@@ -605,6 +629,7 @@ onMounted(() => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
nostrBridge.dispose()
|
||||
registrationBridge.dispose(); rentalBridge.dispose()
|
||||
if (loadTimeoutId) clearTimeout(loadTimeoutId)
|
||||
if (autoRetryId) clearTimeout(autoRetryId)
|
||||
if (iframeCheckId) clearTimeout(iframeCheckId)
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
@select="onIdentitySelected"
|
||||
@cancel="cancelIdentitySelection"
|
||||
/>
|
||||
<RentalPurchaseConsent v-if="!bridge.showConsent.value && !showIdentityPicker && !registrationBridge.request.value" :request="rentalBridge.request.value" :quote="rentalBridge.quote.value" :phase="rentalBridge.phase.value" :error="rentalBridge.error.value" @cancel-unpaid="rentalBridge.cancelUnpaid" @review="rentalBridge.review" @approve="rentalBridge.approve" @cancel="rentalBridge.cancel" />
|
||||
<MediaRegistrationConsent v-if="!bridge.showConsent.value && !showIdentityPicker"
|
||||
:request="registrationBridge.request.value" :phase="registrationBridge.phase.value"
|
||||
:error="registrationBridge.error.value" @approve="registrationBridge.approve" @resolve="registrationBridge.approveResolution" @cancel="registrationBridge.cancel" />
|
||||
<NostrSignConsent
|
||||
:show="bridge.showConsent.value"
|
||||
:app-name="bridge.consentRequest.value?.appName ?? appName"
|
||||
@@ -25,6 +29,10 @@
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
|
||||
import NostrSignConsent from '@/components/NostrSignConsent.vue'
|
||||
import MediaRegistrationConsent from '@/components/MediaRegistrationConsent.vue'
|
||||
import RentalPurchaseConsent from '@/components/RentalPurchaseConsent.vue'
|
||||
import { useMediaRegistrationBridge } from '@/composables/useMediaRegistrationBridge'
|
||||
import { useRentalPurchaseBridge } from '@/composables/useRentalPurchaseBridge'
|
||||
import { useNostrBridge } from '@/views/appSession/useNostrBridge'
|
||||
import type { SelectedIdentity } from '@/views/appSession/useAppIdentity'
|
||||
|
||||
@@ -65,7 +73,7 @@ function hideSigner(delay = 0) {
|
||||
if (hideTimer !== null) clearTimeout(hideTimer)
|
||||
const hide = () => {
|
||||
hideTimer = null
|
||||
if (!showIdentityPicker.value && !bridge.showConsent.value) {
|
||||
if (!showIdentityPicker.value && !bridge.showConsent.value && !registrationBridge.request.value && !rentalBridge.request.value) {
|
||||
parentPost({ type: 'archipelago:signer-hide' })
|
||||
}
|
||||
}
|
||||
@@ -89,6 +97,17 @@ function sendIdentity(identity: SelectedIdentity) {
|
||||
parentPost({ type: 'archipelago:signer-identity', identity: publicIdentity })
|
||||
}
|
||||
|
||||
const registrationBridge = useMediaRegistrationBridge({
|
||||
consentBusy: (): boolean => rentalBridge.isBusy(),
|
||||
appId: () => appId.value, appUrl: () => appOrigin.value, frameWindow: () => window.parent,
|
||||
})
|
||||
const rentalBridge = useRentalPurchaseBridge({
|
||||
consentBusy: (): boolean => bridge.showConsent.value || showIdentityPicker.value || registrationBridge.isBusy(),
|
||||
appId: () => appId.value, appUrl: () => appOrigin.value, frameWindow: () => window.parent,
|
||||
})
|
||||
watch(rentalBridge.request, request => { if (request) showSigner(); else hideSigner() })
|
||||
watch(registrationBridge.request, request => { if (request) showSigner(); else hideSigner() })
|
||||
watch([appId, appOrigin], () => { registrationBridge.cancel(); rentalBridge.cancel() }, { flush: 'sync' })
|
||||
const bridge = useNostrBridge(getStoredIdentity, {
|
||||
appId: () => appId.value,
|
||||
appName: () => appName.value,
|
||||
@@ -185,6 +204,13 @@ function onMessage(event: MessageEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
if (data.type === 'archipelago-rental-request' && appId.value && event.origin === appOrigin.value) {
|
||||
void rentalBridge.handle(event); return
|
||||
}
|
||||
if (data.type === 'archipelago-media-registration-request' && appId.value && event.origin === appOrigin.value) {
|
||||
void registrationBridge.handle(event)
|
||||
return
|
||||
}
|
||||
if (data.type !== 'nostr-request' || !appId.value || event.origin !== appOrigin.value) return
|
||||
if (!getStoredIdentity()) {
|
||||
queuedRequests.push(event)
|
||||
@@ -208,6 +234,7 @@ onMounted(() => {
|
||||
window.parent.postMessage({ type: 'archipelago:signer-ready' }, '*')
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
registrationBridge.dispose(); rentalBridge.dispose()
|
||||
if (hideTimer !== null) clearTimeout(hideTimer)
|
||||
window.removeEventListener('message', onMessage)
|
||||
document.documentElement.classList.remove('nostr-signer-route')
|
||||
|
||||
@@ -377,9 +377,10 @@
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
|
||||
@click.self="closePayModal"
|
||||
>
|
||||
<div class="glass-card w-full max-w-md p-5 rounded-2xl relative">
|
||||
<div class="glass-card w-full max-w-md max-h-[calc(100dvh-2rem)] overflow-y-auto p-5 rounded-2xl relative">
|
||||
<button
|
||||
class="absolute top-3 right-3 text-white/50 hover:text-white transition-colors"
|
||||
class="absolute top-1 right-1 min-h-11 min-w-11 flex items-center justify-center text-white/50 hover:text-white transition-colors"
|
||||
aria-label="Close payment"
|
||||
@click="closePayModal"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -461,18 +462,24 @@
|
||||
<!-- 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-3xl font-bold text-white">{{ ecashPlan.chosen === 'cashu' && cashuQuote ? cashuQuote.wallet_debit_sats : 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>
|
||||
|
||||
<p v-if="ecashPlan.chosen === 'cashu' && cashuQuote" class="text-sm text-white/70 text-center">
|
||||
<span class="block">Cashu · {{ cashuQuote.network === 'testnet' ? 'Testnet' : 'Mainnet' }}</span>
|
||||
<span class="block break-all">Mint: {{ cashuQuote.mint_url }}</span>
|
||||
File: {{ cashuQuote.seller_net_sats }} sats · fees/rounding: {{ cashuQuote.wallet_debit_sats - cashuQuote.seller_net_sats }} sats
|
||||
</p>
|
||||
<p v-if="hasBlockingCashuPurchase" class="text-xs text-white/60">The original purchase is saved on your node. Retry recovers it without starting another payment.</p>
|
||||
<!-- 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', 'ark'] as const)"
|
||||
:key="b"
|
||||
@click="ecashPlan.chosen = b"
|
||||
:disabled="ecashBalanceOf(b) < getItemPrice(payItem.access)"
|
||||
@click="selectEcashBackend(b)"
|
||||
:disabled="paymentActionBusy || (b !== 'cashu' && hasBlockingCashuPurchase) || (b !== 'cashu' && 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'"
|
||||
>
|
||||
@@ -489,6 +496,7 @@
|
||||
|
||||
<p v-if="purchaseError" class="text-sm text-red-400">{{ purchaseError }}</p>
|
||||
|
||||
<button v-if="cashuQuote" class="w-full glass-button px-4 py-2.5 rounded-xl text-sm text-white/70" :disabled="paymentActionBusy" @click="cancelCashuPurchase">Cancel unpaid quote</button>
|
||||
<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"
|
||||
@@ -499,7 +507,7 @@
|
||||
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 || paymentActionBusy"
|
||||
@click="confirmEcashPay"
|
||||
>{{ paymentActionBusy ? 'Paying…' : 'Pay' }}</button>
|
||||
>{{ paymentActionBusy ? 'Working…' : ecashPlan.chosen === 'cashu' && !cashuQuote ? 'Check original purchase' : 'Pay' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -595,6 +603,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { parseCashuQuote, readCashuAttempt, keepCashuAttempt, keepAuthoritativeCashuQuote, archiveMalformedCashuAttempt, clearCashuAttempt, type CashuQuote } from '@/composables/peerCashuPurchase'
|
||||
import { usePeerPaymentOperations } from '@/composables/peerPaymentOperations'
|
||||
import { ref, computed, reactive, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -835,6 +844,33 @@ const ecashPlan = ref<{
|
||||
chosen: EcashBackend | null
|
||||
} | null>(null)
|
||||
const ecashPreparing = ref(false)
|
||||
const cashuQuote = ref<CashuQuote | null>(null)
|
||||
const cashuRecoveryRequired = ref(false)
|
||||
const cashuRecoveryError = ref(false)
|
||||
const hasBlockingCashuPurchase = computed(() => cashuRecoveryRequired.value || cashuRecoveryError.value)
|
||||
let cashuLookup: Promise<void> = Promise.resolve()
|
||||
async function lookupCashuPurchase(onion: string, item: CatalogItem, generation: number) {
|
||||
const selected = () => paymentGeneration.value === generation && activePaymentMatches(onion, item.id)
|
||||
try {
|
||||
const state = await rpcClient.call<{attempts?: Array<{operation_id?: string;state?: string}>}>({method:'content.payment-status',params:{onion,content_id:item.id},timeout:15000})
|
||||
if (!Array.isArray(state?.attempts)) throw new Error('Could not verify saved purchases; recover the original payment before choosing another method.')
|
||||
if (selected() && state.attempts.some(attempt => attempt.state !== 'cancelled_unspent')) {
|
||||
cashuRecoveryRequired.value=true
|
||||
lnError.value='A Cashu purchase is saved on this node. Choose ecash to recover or cancel that operation.'
|
||||
}
|
||||
} catch (error) {
|
||||
if (selected()) { cashuRecoveryError.value=true;lnError.value=error instanceof Error?error.message:'Could not verify saved purchases' }
|
||||
}
|
||||
}
|
||||
async function permitFreshOtherRail(item: CatalogItem, onion: string) {
|
||||
const generation=paymentGeneration.value
|
||||
await cashuLookup
|
||||
if (paymentGeneration.value!==generation || !activePaymentMatches(onion,item.id)) return false
|
||||
if (hasBlockingCashuPurchase.value) { lnError.value='Recover or cancel the saved Cashu purchase before choosing another method.'; return false }
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
// 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')
|
||||
@@ -877,6 +913,13 @@ function keepFailedLightningAttempt(onion: string, id: string, receipt: Lightnin
|
||||
keepReceipt(onion, id, { ...receipt, state: 'failed', failure_reason: reason }, selected)
|
||||
if (selected()) lnError.value = `Lightning attempt failed: ${reason}. No sats were sent by this attempt. You can choose another payment method.`
|
||||
}
|
||||
type InvoiceLifecycle = { paid?: boolean; state?: string; can_switch_method?: boolean }
|
||||
function keepCanceledInvoice(onion: string, id: string, receipt: LightningReceipt, result: InvoiceLifecycle | undefined, selected: () => boolean) {
|
||||
if (receipt.state === 'succeeded' || result?.paid !== false || result.state !== 'canceled' || result.can_switch_method !== true) return false
|
||||
keepReceipt(onion, id, { ...receipt, state: 'failed', failure_reason: 'Seller confirmed the invoice is canceled and unpaid' }, selected)
|
||||
if (selected()) lnError.value = 'The seller confirmed this invoice is canceled and unpaid. You can choose another payment method.'
|
||||
return true
|
||||
}
|
||||
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
|
||||
// can distinguish that from a lost reply; never infer failure from error text.
|
||||
@@ -890,6 +933,14 @@ async function recoverFailedLightningAttempt(onion: string, id: string, receipt:
|
||||
}
|
||||
if (result?.status === 'pending' || result?.status === 'in_flight') return 'pending'
|
||||
} catch { /* unavailable/unknown is still recoverable, never permission to pay again */ }
|
||||
try {
|
||||
const result = await rpcClient.call<InvoiceLifecycle>({
|
||||
method: 'content.invoice-status', params: { onion, content_id: id, payment_hash: receipt.payment_hash }, timeout: 15000,
|
||||
})
|
||||
if (keepCanceledInvoice(onion, id, receipt, result, selected)) return 'failed'
|
||||
if (result?.paid === true) keepReceipt(onion, id, { ...receipt, state: 'succeeded' }, selected)
|
||||
else if (result?.state === 'open' || result?.state === 'accepted') return 'pending'
|
||||
} catch { /* Seller outage or old boolean-only response cannot authorize another payment. */ }
|
||||
return 'other'
|
||||
}
|
||||
const onchainPaying = ref(false)
|
||||
@@ -1130,6 +1181,13 @@ async function downloadFile(item: CatalogItem) {
|
||||
|
||||
function openPayModal(item: CatalogItem) {
|
||||
paymentGeneration.value++
|
||||
cashuQuote.value = null
|
||||
cashuRecoveryRequired.value = false
|
||||
cashuRecoveryError.value = false
|
||||
try {
|
||||
const saved = readCashuAttempt(props.peerId || currentPeer.value?.onion || '', item.id)
|
||||
if (saved) { cashuQuote.value = saved.quote; cashuRecoveryRequired.value = true }
|
||||
} catch { cashuRecoveryError.value = true }
|
||||
payItem.value = item
|
||||
payMode.value = 'choose'
|
||||
qrTab.value = 'onchain'
|
||||
@@ -1152,6 +1210,7 @@ function openPayModal(item: CatalogItem) {
|
||||
if (lnReceipt.value?.state === 'failed') lnError.value = `Previous Lightning attempt failed: ${lnReceipt.value.failure_reason || 'Payment failed'}. You can choose another method.`
|
||||
} catch { lnReceiptReadError.value = true; lnError.value = 'Saved payment could not be read. Do not pay again.' }
|
||||
onchainPaying.value = false
|
||||
cashuLookup = lookupCashuPurchase(props.peerId || currentPeer.value?.onion || '', item, paymentGeneration.value)
|
||||
}
|
||||
|
||||
function closePayModal() {
|
||||
@@ -1161,6 +1220,9 @@ function closePayModal() {
|
||||
payItem.value = null
|
||||
payMode.value = 'choose'
|
||||
ecashPlan.value = null
|
||||
cashuQuote.value = null
|
||||
cashuRecoveryRequired.value = false
|
||||
cashuRecoveryError.value = false
|
||||
ecashPreparing.value = false
|
||||
invoiceWaiting.value = false
|
||||
onchainWaiting.value = false
|
||||
@@ -1232,6 +1294,7 @@ async function loadOnchainQr() {
|
||||
const item = payItem.value
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!item || !onion) return
|
||||
if (!await permitFreshOtherRail(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
|
||||
@@ -1280,6 +1343,7 @@ async function payOnchain() {
|
||||
const item = payItem.value
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!item || !onion || paymentActionBusy.value) return
|
||||
if (!await permitFreshOtherRail(item, onion)) 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)
|
||||
@@ -1348,6 +1412,9 @@ async function prepareEcashPay() {
|
||||
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 }
|
||||
const generation = paymentGeneration.value
|
||||
await cashuLookup
|
||||
if (paymentGeneration.value !== generation || !activePaymentMatches(onion, item.id)) return
|
||||
const operation = paymentOperations.begin('prepare-ecash', onion, item.id)
|
||||
if (!operation) return
|
||||
const price = getItemPrice(item.access)
|
||||
@@ -1372,12 +1439,15 @@ async function prepareEcashPay() {
|
||||
// Prefer Cashu when it covers the price, else Fedimint, else Ark, else
|
||||
// leave null (insufficient — shown in the confirm screen, Confirm disabled).
|
||||
const chosen: EcashBackend | null =
|
||||
cashu >= price ? 'cashu' : fedimint >= price ? 'fedimint' : ark >= price ? 'ark' : null
|
||||
acceptsMethod(item.access, 'ecash') || hasBlockingCashuPurchase.value ? 'cashu' : fedimint >= price ? 'fedimint' : ark >= price ? 'ark' : null
|
||||
ecashPlan.value = { cashu, fedimint, ark, total, chosen }
|
||||
if (!chosen) {
|
||||
purchaseError.value = `Not enough funds: Cashu ${cashu} + Fedimint ${fedimint} + Ark ${ark} sats, need ${price}. Fund a wallet, or pay another way.`
|
||||
}
|
||||
payMode.value = 'ecash-confirm'
|
||||
if (chosen === 'cashu') await requestCashuPurchase(item, onion, operation, false)
|
||||
} catch (error) {
|
||||
if (paymentOperations.selected(operation)) purchaseError.value = error instanceof Error ? error.message : 'Could not recover the Cashu purchase'
|
||||
} finally {
|
||||
if (paymentOperations.finish(operation)) ecashPreparing.value = false
|
||||
}
|
||||
@@ -1420,12 +1490,80 @@ function openPurchased(item: CatalogItem, base64Data: string | undefined, mimeTy
|
||||
void loadOwned()
|
||||
}
|
||||
|
||||
type CashuPurchaseReply = Partial<Omit<CashuQuote, 'state'>> & { state?: string; owned?: boolean; owned_content_id?: string; mime_type?: string; error?: string }
|
||||
async function requestCashuPurchase(item: CatalogItem, onion: string, operation: NonNullable<ReturnType<typeof paymentOperations.begin>>, confirm: boolean) {
|
||||
const selected = () => paymentOperations.selected(operation)
|
||||
let saved: ReturnType<typeof readCashuAttempt> = null
|
||||
let unreadable = false
|
||||
try { saved = readCashuAttempt(onion, item.id) } catch { unreadable = true }
|
||||
// A corrupt browser marker may query/recover node-owned state, but cannot
|
||||
// supply consent or authorize a newly selected payment.
|
||||
const quote = unreadable ? null : saved?.quote || (selected() ? cashuQuote.value : null)
|
||||
if (confirm && quote) keepCashuAttempt(onion, item.id, quote, true)
|
||||
const result = await rpcClient.call<CashuPurchaseReply>({
|
||||
method: 'content.purchase',
|
||||
params: { onion, content_id: item.id, filename: item.filename,
|
||||
max_wallet_debit: confirm && quote ? quote.wallet_debit_sats : Number.MAX_SAFE_INTEGER,
|
||||
...(confirm && quote ? { consent: { operation_id: quote.operation_id, envelope_sha256: quote.envelope_sha256, wallet_debit_sats: quote.wallet_debit_sats } } : {}) },
|
||||
timeout: 960000, maxRetries: 1,
|
||||
})
|
||||
if (result?.state === 'confirmation_required') {
|
||||
const next = parseCashuQuote(result)
|
||||
keepAuthoritativeCashuQuote(onion, item.id, next)
|
||||
if (selected()) { cashuQuote.value = next; cashuRecoveryRequired.value = true; cashuRecoveryError.value = false }
|
||||
return
|
||||
}
|
||||
if (result?.state === 'delivered' && result.owned === true && result.owned_content_id) {
|
||||
archiveMalformedCashuAttempt(onion, item.id)
|
||||
clearCashuAttempt(onion, item.id)
|
||||
if (selected()) openPurchased(item, undefined, result.mime_type, onion, result.owned_content_id)
|
||||
return
|
||||
}
|
||||
if (result?.state === 'cancelled_unspent') {
|
||||
archiveMalformedCashuAttempt(onion, item.id)
|
||||
clearCashuAttempt(onion, item.id)
|
||||
if (selected()) { cashuQuote.value = null; cashuRecoveryRequired.value = false; cashuRecoveryError.value = false; payMode.value = 'choose' }
|
||||
return
|
||||
}
|
||||
throw new Error(result?.error || 'The original Cashu purchase is not confirmed yet. Retry recovers it; do not pay using another method.')
|
||||
}
|
||||
async function selectEcashBackend(backend: EcashBackend) {
|
||||
if (!ecashPlan.value || paymentActionBusy.value) return
|
||||
if (backend !== 'cashu' && hasBlockingCashuPurchase.value) { purchaseError.value = 'Cancel the original unpaid Cashu quote before selecting another wallet.'; return }
|
||||
ecashPlan.value.chosen = backend
|
||||
if (backend === 'cashu') await prepareEcashPay()
|
||||
}
|
||||
async function cancelCashuPurchase() {
|
||||
const item = payItem.value
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
const quote = cashuQuote.value
|
||||
if (!item || !onion || !quote || paymentActionBusy.value) return
|
||||
const generation = paymentGeneration.value
|
||||
await cashuLookup
|
||||
if (paymentGeneration.value !== generation || !activePaymentMatches(onion,item.id)) return
|
||||
const operation = paymentOperations.begin('cashu-cancel', onion, item.id)
|
||||
if (!operation) return
|
||||
try {
|
||||
const result = await rpcClient.call<{state?: string;operation_id?: string}>({ method:'content.cancel-purchase',
|
||||
params:{onion,operation_id:quote.operation_id}, timeout:60000,maxRetries:1 })
|
||||
if (result?.state !== 'cancelled_unspent' || result.operation_id !== quote.operation_id) throw new Error('Cancellation is not confirmed. Recover the original purchase before paying another way.')
|
||||
clearCashuAttempt(onion,item.id)
|
||||
if (paymentOperations.selected(operation)) {
|
||||
cashuQuote.value=null;cashuRecoveryRequired.value=false;cashuRecoveryError.value=false
|
||||
purchaseError.value=null;payMode.value='choose'
|
||||
}
|
||||
} catch (error) {
|
||||
if (paymentOperations.selected(operation)) purchaseError.value=error instanceof Error?error.message:'Could not confirm cancellation'
|
||||
} finally { paymentOperations.finish(operation) }
|
||||
}
|
||||
|
||||
/** Confirm the ecash payment with the backend the user selected. */
|
||||
async function confirmEcashPay() {
|
||||
const item = payItem.value
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
const method = ecashPlan.value?.chosen
|
||||
if (!item || !onion || !method || paymentActionBusy.value || hasBlockingLightningReceipt.value) return
|
||||
if (method !== 'cashu' && !await permitFreshOtherRail(item, onion)) return
|
||||
const operation = paymentOperations.begin('ecash-send', onion, item.id)
|
||||
if (!operation) return
|
||||
const selected = () => paymentOperations.selected(operation)
|
||||
@@ -1433,6 +1571,8 @@ async function confirmEcashPay() {
|
||||
|
||||
purchaseError.value = null
|
||||
try {
|
||||
if (method === 'cashu') { await requestCashuPurchase(item, onion, operation, true); return }
|
||||
if (hasBlockingCashuPurchase.value) throw new Error('Recover or cancel the saved Cashu purchase first.')
|
||||
const result = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; error?: string; ecash_backend?: string; mime_type?: string }>({
|
||||
method: 'content.download-peer-paid',
|
||||
params: { onion, content_id: item.id, price_sats: price, method, filename: item.filename, cache_only: true },
|
||||
@@ -1457,6 +1597,7 @@ async function payWithInvoice() {
|
||||
const item = payItem.value
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!item || !onion) return
|
||||
if (!await permitFreshOtherRail(item, onion)) return
|
||||
const operation = paymentOperations.begin('invoice', onion, item.id)
|
||||
if (!operation) return
|
||||
payMode.value = 'qr'
|
||||
@@ -1495,6 +1636,7 @@ async function payWithLightning() {
|
||||
const item = payItem.value
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!item || !onion || paymentActionBusy.value || lnReceiptReadError.value) return
|
||||
if (!await permitFreshOtherRail(item, onion)) return
|
||||
const operation = paymentOperations.begin('lightning', onion, item.id)
|
||||
if (!operation) return
|
||||
const selected = () => paymentOperations.selected(operation)
|
||||
@@ -1568,11 +1710,18 @@ async function pollInvoice(scope: InvoicePollScope) {
|
||||
if (!invoiceScopeSelected(scope)) return
|
||||
const { item, onion, invoice: inv } = scope
|
||||
try {
|
||||
const res = await rpcClient.call<{ paid?: boolean }>({
|
||||
const res = await rpcClient.call<InvoiceLifecycle>({
|
||||
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 }, timeout: 30000,
|
||||
})
|
||||
if (!invoiceScopeSelected(scope)) return
|
||||
if (keepCanceledInvoice(onion, item.id, inv, res, () => invoiceScopeSelected(scope))) {
|
||||
invoiceWaiting.value = false
|
||||
invoiceData.value = null
|
||||
invoiceQr.value = ''
|
||||
payMode.value = 'choose'
|
||||
return
|
||||
}
|
||||
if (res?.paid === true) {
|
||||
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 }>({
|
||||
|
||||
@@ -7,8 +7,9 @@ vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn(), federationListNodes: vi.fn(), payLightningInvoice: vi.fn() } }))
|
||||
vi.mock('@/composables/useAudioPlayer', () => ({ useAudioPlayer: () => ({ play: vi.fn() }) }))
|
||||
const hash = 'a'.repeat(64)
|
||||
const item = { id: 'paid-file', filename: 'bought.txt', mime_type: 'text/plain', size_bytes: 4, description: '', access: { paid: { price_sats: 5, accepted: ['lightning'] } } }
|
||||
const item = { id: 'paid-file', filename: 'bought.txt', mime_type: 'text/plain', size_bytes: 4, description: '', access: { paid: { price_sats: 5, accepted: ['lightning', 'ecash'] } } }
|
||||
const receiptKey = 'peer-file-lightning:peer.onion:paid-file'
|
||||
const cashuQuoteFixture = { state: 'confirmation_required', network: 'mainnet', mint_url: 'https://original-mint.example.test', operation_id: '12345678-1234-4234-8234-123456789abc', envelope_sha256: 'b'.repeat(64), gross_token_sats: 6, seller_net_sats: 5, wallet_debit_sats: 7, expires_at: 2_000_000_000 }
|
||||
const download = vi.fn()
|
||||
async function open() {
|
||||
const wrapper = mount(PeerFiles, { props: { peerId: 'peer.onion' }, global: { plugins: [createPinia()], stubs: { Teleport: true } } })
|
||||
@@ -23,9 +24,10 @@ beforeEach(() => {
|
||||
localStorage.clear(); vi.clearAllMocks()
|
||||
vi.mocked(rpcClient.federationListNodes).mockResolvedValue({ nodes: [] } as never)
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||
if (method === 'content.purchase') return cashuQuoteFixture
|
||||
if (method === 'content.request-invoice') return { bolt11: 'ln-test', payment_hash: hash, price_sats: 5 }
|
||||
if (method === 'content.download-peer-invoice') return download()
|
||||
return { items: [] }
|
||||
return { items: [], attempts: [] }
|
||||
})
|
||||
vi.mocked(rpcClient.payLightningInvoice).mockResolvedValue({ status: 'succeeded' } as never)
|
||||
})
|
||||
@@ -34,7 +36,7 @@ describe('Lightning file delivery recovery', () => {
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||
if (method === 'content.onchain-status') return { paid: true }
|
||||
if (method === 'content.download-peer-onchain') return { owned: true, mime_type: 'video/mp4', size_bytes: 200000000 }
|
||||
return { items: [] }
|
||||
return { items: [], attempts: [] }
|
||||
})
|
||||
const { wrapper, vm } = await open()
|
||||
await vm.pollOnchain('bc1test')
|
||||
@@ -47,29 +49,29 @@ describe('Lightning file delivery recovery', () => {
|
||||
})
|
||||
it('opens a cached ecash purchase without transferring base64 into the UI', async () => {
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||
if (method === 'content.download-peer-paid') return { owned: true, mime_type: 'video/mp4', size_bytes: 200000000 }
|
||||
return { items: [] }
|
||||
if (method === 'content.purchase') return { state: 'delivered', owned: true, owned_content_id: item.id, mime_type: 'video/mp4', size_bytes: 200000000 }
|
||||
return { items: [], attempts: [] }
|
||||
})
|
||||
const { wrapper, vm } = await open()
|
||||
vm.ecashPlan = { cashu: 10, fedimint: 0, ark: 0, total: 10, chosen: 'cashu' }
|
||||
await vm.confirmEcashPay()
|
||||
expect(vm.viewerUrl).toBe('/api/peer-content/peer.onion/paid-file')
|
||||
expect(vm.viewerMime).toBe('video/mp4')
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.download-peer-paid')![0].params).toMatchObject({ cache_only: true, method: 'cashu' })
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.download-peer-paid')![0].maxRetries).toBe(1)
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.purchase')![0].params).toMatchObject({ content_id: item.id, max_wallet_debit: Number.MAX_SAFE_INTEGER })
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.purchase')![0].maxRetries).toBe(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('does not issue a duplicate ecash purchase while delivery is pending', async () => {
|
||||
let finish!: (value: unknown) => void
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||
if (method === 'content.download-peer-paid') return await new Promise(resolve => { finish = resolve })
|
||||
return { items: [] }
|
||||
if (method === 'content.purchase') return await new Promise(resolve => { finish = resolve })
|
||||
return { items: [], attempts: [] }
|
||||
})
|
||||
const { wrapper, vm } = await open()
|
||||
vm.ecashPlan = { cashu: 10, fedimint: 0, ark: 0, total: 10, chosen: 'cashu' }
|
||||
const first = vm.confirmEcashPay()
|
||||
await vm.confirmEcashPay()
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.download-peer-paid')).toHaveLength(1)
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.purchase')).toHaveLength(1)
|
||||
finish({ error: 'Delivery needs recovery; do not pay again' })
|
||||
await first
|
||||
expect(vm.purchaseError).toContain('do not pay again')
|
||||
@@ -214,9 +216,11 @@ describe('Lightning file delivery recovery', () => {
|
||||
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: [] })
|
||||
? await new Promise(resolve => { reply = resolve }) : { items: [], attempts: [] })
|
||||
const { wrapper, vm } = await open()
|
||||
const pending = vm.payWithLightning()
|
||||
await flushPromises()
|
||||
expect(typeof reply).toBe('function')
|
||||
vm.closePayModal(); vm.openPayModal(item)
|
||||
reply({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5 })
|
||||
await pending
|
||||
@@ -229,9 +233,11 @@ it('does not pay when an invoice arrives after closing and reopening the same fi
|
||||
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: [] })
|
||||
? await new Promise(resolve => { reply = resolve }) : { items: [], attempts: [] })
|
||||
const { wrapper, vm } = await open()
|
||||
const pending = vm.payWithInvoice()
|
||||
await flushPromises()
|
||||
expect(typeof reply).toBe('function')
|
||||
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' })
|
||||
@@ -247,11 +253,15 @@ it('retains a late invoice for its original file without changing another file m
|
||||
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: [] })
|
||||
? await new Promise(resolve => { replies.push(resolve) }) : { items: [], attempts: [] })
|
||||
const { wrapper, vm } = await open()
|
||||
const first = vm.prepareEcashPay()
|
||||
await flushPromises()
|
||||
expect(replies).toHaveLength(1)
|
||||
vm.closePayModal(); vm.openPayModal({ ...item, id: 'second-file' })
|
||||
const second = vm.prepareEcashPay()
|
||||
await flushPromises()
|
||||
expect(replies).toHaveLength(2)
|
||||
replies[0]!({ cashu_sats: 100 })
|
||||
await first
|
||||
expect(vm.ecashPreparing).toBe(true)
|
||||
@@ -267,7 +277,7 @@ it('cannot deliver a late paid invoice into another file modal', async () => {
|
||||
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: [] }
|
||||
return { items: [], attempts: [] }
|
||||
})
|
||||
const { wrapper, vm } = await open()
|
||||
const invoice = { bolt11: 'ln-test', payment_hash: hash, price_sats: 5 }
|
||||
@@ -304,9 +314,11 @@ it('persists a dispatched Lightning result after closing without changing anothe
|
||||
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: [] })
|
||||
? await new Promise(resolve => { reply = resolve }) : { items: [], attempts: [] })
|
||||
const { wrapper, vm } = await open()
|
||||
const pending = vm.payWithLightning()
|
||||
await flushPromises()
|
||||
expect(typeof reply).toBe('function')
|
||||
wrapper.unmount()
|
||||
reply({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5 })
|
||||
await pending
|
||||
@@ -341,7 +353,7 @@ it('retains already dispatched Lightning evidence after unmount without opening
|
||||
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: [] }
|
||||
return { items: [], attempts: [] }
|
||||
})
|
||||
const { wrapper, vm } = await open()
|
||||
vm.openPayModal({ ...item, access: { paid: { price_sats: 546, accepted: ['onchain', 'lightning', 'ecash'] } } })
|
||||
@@ -404,3 +416,151 @@ it('retains already dispatched Lightning evidence after unmount without opening
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
|
||||
describe('Seller-authoritative external invoice lifecycle', () => {
|
||||
it('unlocks alternate methods only after the seller confirms the saved external invoice canceled and unpaid', async () => {
|
||||
localStorage.setItem(receiptKey, JSON.stringify({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5, state: 'pending' }))
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||
if (method === 'lnd.paymentstatus') throw new Error('Unknown external payment')
|
||||
if (method === 'content.invoice-status') return { paid: false, state: 'canceled', can_switch_method: true }
|
||||
return { items: [], attempts: [] }
|
||||
})
|
||||
const { wrapper, vm } = await open()
|
||||
await vm.payWithLightning()
|
||||
expect(vm.hasBlockingLightningReceipt).toBe(false)
|
||||
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ payment_hash: hash, state: 'failed' })
|
||||
expect(vm.lnError).toContain('seller confirmed')
|
||||
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.some(([call]) => ['content.request-invoice', 'content.download-peer-invoice'].includes(call.method))).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each([
|
||||
{ paid: false },
|
||||
{ paid: false, state: 'open', expires_at: 1, can_switch_method: false },
|
||||
{ paid: false, state: 'unknown', can_switch_method: false },
|
||||
{ paid: false, state: 'canceled' },
|
||||
{ paid: false, state: 'accepted', can_switch_method: true },
|
||||
])('retains the saved attempt when seller status does not prove terminal cancellation: %j', async response => {
|
||||
localStorage.setItem(receiptKey, JSON.stringify({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5, state: 'pending' }))
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||
if (method === 'lnd.paymentstatus') throw new Error('Unknown external payment')
|
||||
if (method === 'content.invoice-status') return response
|
||||
if (method === 'content.download-peer-invoice') return { error: 'Payment is still pending' }
|
||||
return { items: [], attempts: [] }
|
||||
})
|
||||
const { wrapper, vm } = await open()
|
||||
await vm.payWithLightning()
|
||||
expect(vm.hasBlockingLightningReceipt).toBe(true)
|
||||
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ payment_hash: hash, state: 'pending' })
|
||||
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.some(([call]) => call.method === 'content.request-invoice')).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Durable node Cashu purchases', () => {
|
||||
it('shows the exact quoted debit and confirms its immutable terms without the legacy send RPC', async () => {
|
||||
let purchaseCalls = 0
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({method}) => {
|
||||
if (method === 'content.purchase') return ++purchaseCalls === 1 ? cashuQuoteFixture : {state:'delivered',owned:true,owned_content_id:item.id,mime_type:'text/plain'}
|
||||
return {items:[],attempts:[]}
|
||||
})
|
||||
const {wrapper,vm}=await open()
|
||||
await vm.prepareEcashPay()
|
||||
expect(vm.cashuQuote.wallet_debit_sats).toBe(7)
|
||||
expect(wrapper.text()).toContain('fees/rounding: 2 sats')
|
||||
expect(purchaseCalls).toBe(1)
|
||||
await vm.confirmEcashPay()
|
||||
const calls=vi.mocked(rpcClient.call).mock.calls.map(([call])=>call)
|
||||
const requests=calls.filter(call=>call.method==='content.purchase')
|
||||
expect(requests[0]?.params).not.toHaveProperty('consent')
|
||||
expect(requests[1]?.params).toMatchObject({max_wallet_debit:7,consent:{operation_id:cashuQuoteFixture.operation_id,envelope_sha256:cashuQuoteFixture.envelope_sha256,wallet_debit_sats:7}})
|
||||
expect(calls.some(call=>call.method==='content.download-peer-paid')).toBe(false)
|
||||
expect(vm.viewerUrl).toContain('/paid-file')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('recovers after a lost submit reply and remount without confirming another payment', async () => {
|
||||
let count=0
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({method})=>{
|
||||
if (method==='content.purchase') {
|
||||
count++
|
||||
if(count===1)return cashuQuoteFixture
|
||||
if(count===2)throw new Error('Connection lost; original purchase saved')
|
||||
return {state:'delivered',owned:true,owned_content_id:item.id,mime_type:'text/plain'}
|
||||
}
|
||||
return {items:[],attempts:[]}
|
||||
})
|
||||
const first=await open();await first.vm.prepareEcashPay();await first.vm.confirmEcashPay();first.wrapper.unmount()
|
||||
const next=await open();expect(next.vm.hasBlockingCashuPurchase).toBe(true)
|
||||
await next.vm.payWithLightning();expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
|
||||
await next.vm.prepareEcashPay()
|
||||
const requests=vi.mocked(rpcClient.call).mock.calls.filter(([call])=>call.method==='content.purchase')
|
||||
expect(requests).toHaveLength(3)
|
||||
expect(requests[2]?.[0].params).not.toHaveProperty('consent')
|
||||
expect(next.vm.viewerUrl).toContain('/paid-file')
|
||||
next.wrapper.unmount()
|
||||
})
|
||||
it('keeps the original operation until seller cancellation acknowledgement, then permits a new quote', async () => {
|
||||
let canceled=false, cancelCalls=0
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({method})=>{
|
||||
if(method==='content.purchase')return canceled?{...cashuQuoteFixture,operation_id:'87654321-1234-4234-8234-123456789abc'}:cashuQuoteFixture
|
||||
if(method==='content.cancel-purchase'){
|
||||
if(++cancelCalls===1)throw new Error('Cancellation reply lost')
|
||||
canceled=true;return {state:'cancelled_unspent',operation_id:cashuQuoteFixture.operation_id}
|
||||
}
|
||||
return {items:[],attempts:[]}
|
||||
})
|
||||
const {wrapper,vm}=await open();await vm.prepareEcashPay();await vm.cancelCashuPurchase()
|
||||
expect(vm.hasBlockingCashuPurchase).toBe(true)
|
||||
await vm.payWithLightning();expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
|
||||
await vm.cancelCashuPurchase();expect(vm.hasBlockingCashuPurchase).toBe(false)
|
||||
await vm.prepareEcashPay();expect(vm.cashuQuote.operation_id).not.toBe(cashuQuoteFixture.operation_id)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('finds a node-owned pending purchase after browser state loss before another rail can dispatch', async () => {
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({method})=>{
|
||||
if(method==='content.payment-status')return {attempts:[{operation_id:cashuQuoteFixture.operation_id,state:'token_prepared_settlement_unconfirmed'}]}
|
||||
if(method==='content.purchase')return {state:'delivered',owned:true,owned_content_id:item.id,mime_type:'text/plain'}
|
||||
return {items:[],attempts:[]}
|
||||
})
|
||||
const {wrapper,vm}=await open();await vm.payWithLightning()
|
||||
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.some(([call])=>call.method==='content.request-invoice')).toBe(false)
|
||||
await vm.prepareEcashPay();expect(vm.viewerUrl).toContain('/paid-file')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Malformed browser Cashu marker recovery', () => {
|
||||
const marker='peer-file-cashu:peer.onion:paid-file'
|
||||
it('queries node-owned state without consent, archives the malformed marker and restores the authoritative quote', async () => {
|
||||
localStorage.setItem(marker,'{original broken marker')
|
||||
const {wrapper,vm}=await open()
|
||||
expect(vm.cashuRecoveryError).toBe(true)
|
||||
await vm.prepareEcashPay()
|
||||
expect(localStorage.getItem(`${marker}:unreadable`)).toBe('{original broken marker')
|
||||
expect(JSON.parse(localStorage.getItem(marker)!)).toMatchObject({quote:cashuQuoteFixture,dispatched:false})
|
||||
expect(vm.cashuRecoveryError).toBe(false)
|
||||
expect(vm.hasBlockingCashuPurchase).toBe(true)
|
||||
const calls=vi.mocked(rpcClient.call).mock.calls.filter(([call])=>call.method==='content.purchase')
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]?.[0].params).not.toHaveProperty('consent')
|
||||
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps original malformed data and all replacement methods blocked when node recovery is unavailable', async () => {
|
||||
localStorage.setItem(marker,'{original broken marker')
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({method})=>{
|
||||
if(method==='content.purchase')throw new Error('Node purchase journal is unavailable')
|
||||
return {items:[],attempts:[]}
|
||||
})
|
||||
const {wrapper,vm}=await open();await vm.prepareEcashPay();await vm.payWithLightning()
|
||||
expect(localStorage.getItem(marker)).toBe('{original broken marker')
|
||||
expect(localStorage.getItem(`${marker}:unreadable`)).toBeNull()
|
||||
expect(vm.hasBlockingCashuPurchase).toBe(true)
|
||||
expect(vm.purchaseError).toContain('journal is unavailable')
|
||||
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -106,7 +106,7 @@ describe('PeerFiles', () => {
|
||||
}
|
||||
vi.mocked(rpcClient.call).mockImplementation((async (req: { method: string }) => {
|
||||
if (req.method === 'content.browse-peer') return { items: [freeImage] }
|
||||
if (req.method === 'content.owned-list') return { items: [] }
|
||||
if (req.method === 'content.owned-list') return { items: [], attempts: [] }
|
||||
return {}
|
||||
}) as never)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user