import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia } from 'pinia'
import PeerFiles from '../PeerFiles.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('vue-router', () => ({
RouterLink: { template: '' },
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 onchainFixture = { operation_id:'22222222-2222-4222-8222-222222222222',price_sats:546,phase:'quoted',network:'mainnet',external_exposure:false,address:null,fee_sats:null,max_fee_sats:null,template_sha256:null,txid:null,paid:false,change_allocation_ambiguous:false,can_switch_method:false,retired_unallocated:false }
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()
// Models the node's guarded native payment RPC, not a browser-side LND call.
const nativePay = vi.fn()
async function open() {
const wrapper = mount(PeerFiles, { props: { peerId: 'peer.onion' }, global: { plugins: [createPinia()], stubs: { Teleport: true } } })
await flushPromises()
// Drive the actual component payment handlers, asserting RPC effects rather
// than a duplicate implementation of the payment state machine.
const vm = (wrapper.vm as any).$.setupState
vm.openPayModal(item)
await flushPromises()
return { wrapper, vm }
}
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.invoice-create') return { operation_id:'11111111-1111-4111-8111-111111111111', bolt11: 'ln-test', payment_hash: hash, price_sats: 5,external_exposure:false }
if (method === 'content.invoice-pay') return nativePay()
if (method === 'content.download-peer-invoice' || method === 'content.invoice-download') return download()
return { items: [], attempts: [], attempt: null }
})
nativePay.mockReset().mockResolvedValue({ status: 'succeeded' } as never)
download.mockReset().mockResolvedValue({error:'Original payment is unresolved'})
})
describe('Lightning file delivery recovery', () => {
it('opens a confirmed on-chain delivery from HTTP cache without another payment', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'content.onchain-recover') return { ...onchainFixture, paid: true }
if (method === 'content.onchain-download') return { owned: true, mime_type: 'video/mp4', size_bytes: 200000000 }
return { items: [], attempts: [], attempt: null }
})
const { wrapper, vm } = await open()
await vm.pollOnchain(onchainFixture.operation_id)
expect(vm.viewerUrl).toBe('/api/peer-content/peer.onion/paid-file')
expect(vm.viewerMime).toBe('video/mp4')
const calls = vi.mocked(rpcClient.call).mock.calls.map(([call]) => call)
expect(calls.find(call => call.method === 'content.onchain-download')?.params).toMatchObject({ operation_id: onchainFixture.operation_id, content_id: item.id })
expect(calls.some(call => ['lnd.sendcoins', 'content.request-onchain'].includes(call.method))).toBe(false)
wrapper.unmount()
})
it('opens a cached ecash purchase without transferring base64 into the UI', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'content.purchase') return { state: 'delivered', owned: true, owned_content_id: item.id, mime_type: 'video/mp4', size_bytes: 200000000 }
return { items: [], attempts: [], attempt: null }
})
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.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.purchase') return await new Promise(resolve => { finish = resolve })
return { items: [], attempts: [], attempt: null }
})
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.purchase')).toHaveLength(1)
finish({ error: 'Delivery needs recovery; do not pay again' })
await first
expect(vm.purchaseError).toContain('do not pay again')
wrapper.unmount()
})
it('retries delivery after a seller rejection without paying or requesting another invoice', async () => {
download.mockResolvedValue({ error: 'Seller has not registered this payment yet' })
const { wrapper, vm } = await open()
await vm.payWithLightning()
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ payment_hash: hash })
vm.closePayModal(); vm.openPayModal(item)
await vm.payWithLightning()
expect(nativePay).toHaveBeenCalledTimes(1)
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.invoice-create')).toHaveLength(1)
expect(download).toHaveBeenCalledTimes(2)
expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.invoice-download')![0].params).toEqual({onion:'peer.onion',content_id:item.id,operation_id:'11111111-1111-4111-8111-111111111111'})
wrapper.unmount()
})
it('recovers the same node-owned payment operation after an ambiguous reply', async () => {
nativePay.mockRejectedValueOnce(new Error('Connection lost')).mockResolvedValue({status:'succeeded'})
download.mockResolvedValue({ error: 'Pending' })
const first = await open(); await first.vm.payWithLightning(); first.wrapper.unmount()
const second = await open(); await second.vm.payWithLightning()
// RPC recovery may be repeated; the node dispatch-once engine owns spending.
const requests=vi.mocked(rpcClient.call).mock.calls.filter(([v])=>v.method==='content.invoice-pay')
expect(requests).toHaveLength(2)
expect(requests.map(([v])=>v.params)).toEqual([requests[0]![0].params,requests[0]![0].params])
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v])=>v.method==='content.invoice-create')).toHaveLength(1)
expect(download).toHaveBeenCalledTimes(1)
second.wrapper.unmount()
})
it('opens cached delivery through HTTP without a base64 file in the response', async () => {
download.mockResolvedValue({ owned: true, mime_type: 'video/mp4', size_bytes: 206165161 })
const { wrapper, vm } = await open()
await vm.payWithLightning()
expect(vm.viewerUrl).toBe('/api/peer-content/peer.onion/paid-file')
expect(vm.viewerMime).toBe('video/mp4')
expect(localStorage.getItem(receiptKey)).toBeNull()
expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.invoice-download')![0].params).toMatchObject({operation_id:'11111111-1111-4111-8111-111111111111'})
expect(nativePay).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
it('never pays again when the saved receipt is corrupt', async () => {
localStorage.setItem(receiptKey, '{broken')
const { wrapper, vm } = await open()
await vm.payWithLightning()
expect(nativePay).not.toHaveBeenCalled()
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.invoice-create')).toHaveLength(0)
wrapper.unmount()
})
it('keeps QR recovery on the saved Lightning payment instead of creating another rail', async () => {
localStorage.setItem(receiptKey, JSON.stringify({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5 }))
const { wrapper, vm } = await open()
vm.openQrPay(); await flushPromises()
expect(vm.payMode).toBe('qr')
expect(vm.qrTab).toBe('lightning')
vm.selectQrTab('onchain'); await flushPromises()
expect(vm.qrTab).toBe('lightning')
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => ['content.invoice-create', 'content.request-onchain'].includes(v.method))).toHaveLength(0)
wrapper.unmount()
})
it('does not send payment if the recovery record cannot be saved', async () => {
const { wrapper, vm } = await open()
const save = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('Storage full') })
await vm.payWithLightning()
expect(nativePay).not.toHaveBeenCalled()
save.mockRestore(); wrapper.unmount()
})
it('reopens ecash after a returned terminal failure while retaining failed-attempt history', async () => {
nativePay.mockResolvedValue({ status: 'failed', failure_reason: 'Insufficient channel balance' } as never)
const original = vi.mocked(rpcClient.call).getMockImplementation()!
vi.mocked(rpcClient.call).mockImplementation(async args => args.method === 'wallet.ecash-balance' ? { cashu_sats: 10 } : original(args))
const { wrapper, vm } = await open()
vm.openPayModal({ ...item, access: { paid: { price_sats: 5, accepted: ['lightning', 'ecash'] } } })
await vm.payWithLightning(); await flushPromises()
expect(vm.hasBlockingLightningReceipt).toBe(false)
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ state: 'failed', payment_hash: hash })
expect(wrapper.text()).toContain('Pay from this node’s ecash wallet')
await vm.prepareEcashPay()
expect(vm.ecashPlan.chosen).toBe('cashu')
expect(nativePay).toHaveBeenCalledTimes(1)
expect(download).not.toHaveBeenCalled()
wrapper.unmount()
})
it('resolves an old-backend exception using definitive LND state', async () => {
nativePay.mockRejectedValue(new Error('Payment failed: Insufficient channel balance'))
const original = vi.mocked(rpcClient.call).getMockImplementation()!
vi.mocked(rpcClient.call).mockImplementation(async args => args.method === 'lnd.paymentstatus' ? { status: 'failed', failure_reason: 'Insufficient channel balance' } : original(args))
const { wrapper, vm } = await open(); await vm.payWithLightning()
expect(vm.hasBlockingLightningReceipt).toBe(false)
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ state: 'failed' })
expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'lnd.paymentstatus')![0].params).toEqual({ payment_hash: hash })
expect(download).not.toHaveBeenCalled()
wrapper.unmount()
})
it('does not treat a legacy receipt of unknown exposure as canceled from local LND failure', async () => {
localStorage.setItem(receiptKey, JSON.stringify({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5 }))
const original = vi.mocked(rpcClient.call).getMockImplementation()!
vi.mocked(rpcClient.call).mockImplementation(async args => args.method === 'lnd.paymentstatus' ? { status: 'failed', failure_reason: 'No route' } : original(args))
const { wrapper, vm } = await open(); await vm.payWithLightning()
expect(vm.hasBlockingLightningReceipt).toBe(true)
expect(nativePay).not.toHaveBeenCalled()
expect(vi.mocked(rpcClient.call).mock.calls.some(([v]) => v.method === 'content.invoice-create')).toBe(false)
wrapper.unmount()
})
it('keeps ambiguous attempts recoverable and prevents another payment method', async () => {
nativePay.mockRejectedValue(new Error('Connection lost'))
const original = vi.mocked(rpcClient.call).getMockImplementation()!
vi.mocked(rpcClient.call).mockImplementation(async args => args.method === 'lnd.paymentstatus' ? { status: 'unknown' } : original(args))
const { wrapper, vm } = await open(); await vm.payWithLightning()
expect(vm.hasBlockingLightningReceipt).toBe(true)
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ state: 'pending' })
await vm.prepareEcashPay()
vm.ecashPlan = { cashu: 10, fedimint: 0, ark: 0, total: 10, chosen: 'cashu' }
await vm.confirmEcashPay(); await vm.payOnchain()
expect(vi.mocked(rpcClient.call).mock.calls.some(([v]) => ['content.download-peer-paid', 'content.request-onchain', 'lnd.sendcoins'].includes(v.method))).toBe(false)
expect(nativePay).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
it('returns known in-flight recovery promptly without a long delivery call or another payment', async () => {
localStorage.setItem(receiptKey, JSON.stringify({ bolt11: 'saved', payment_hash: hash, price_sats: 5, state: 'pending' }))
const original = vi.mocked(rpcClient.call).getMockImplementation()!
vi.mocked(rpcClient.call).mockImplementation(async args => args.method === 'lnd.paymentstatus' ? { status: 'pending' } : original(args))
const { wrapper, vm } = await open()
await vm.payWithLightning()
expect(vm.lnPaying).toBe(false)
expect(vm.lnError).toContain('close this window')
expect(vi.mocked(rpcClient.call).mock.calls.some(([c]) => ['content.invoice-create', 'content.download-peer-invoice'].includes(c.method))).toBe(false)
expect(nativePay).not.toHaveBeenCalled()
vm.closePayModal()
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ state: 'pending' })
wrapper.unmount()
})
it('requests a fresh QR invoice after a confirmed failed local attempt', async () => {
localStorage.setItem(receiptKey, JSON.stringify({ bolt11: 'failed-old', payment_hash: hash, price_sats: 5, state: 'failed' }))
const { wrapper, vm } = await open()
await vm.payWithInvoice()
expect(vi.mocked(rpcClient.call).mock.calls.filter(([c]) => c.method === 'content.invoice-create')).toHaveLength(1)
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ bolt11: 'ln-test', state: 'pending' })
expect(nativePay).not.toHaveBeenCalled()
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.invoice-create'
? await new Promise(resolve => { reply = resolve }) : { items: [], attempts: [], attempt: null })
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
expect(nativePay).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.invoice-create'
? await new Promise(resolve => { reply = resolve }) : { items: [], attempts: [], attempt: null })
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.invoice-create')).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: [], attempts: [], attempt: null })
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)
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: [], attempts: [], attempt: null }
})
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
nativePay.mockImplementation(async () => await new Promise(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(nativePay).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.invoice-create'
? await new Promise(resolve => { reply = resolve }) : { items: [], attempts: [], attempt: null })
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
expect(nativePay).not.toHaveBeenCalled()
expect(localStorage.getItem(receiptKey)).toBeNull()
})
it('retains already dispatched Lightning evidence after unmount without opening playback', async () => {
let reply!: (value: unknown) => void
nativePay.mockImplementation(async () => await new Promise(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.onchain-create', 'content.onchain-expose', 'content.onchain-pay', '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.onchain-create') return { ...onchainFixture, price_sats: 547 }
return { items: [], attempts: [], attempt: null }
})
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.onchain-create')).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(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')
}
})
})
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: [], attempt: null }
})
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(nativePay).not.toHaveBeenCalled()
expect(vi.mocked(rpcClient.call).mock.calls.some(([call]) => ['content.invoice-create', '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: [], attempt: null }
})
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(nativePay).not.toHaveBeenCalled()
expect(vi.mocked(rpcClient.call).mock.calls.some(([call]) => call.method === 'content.invoice-create')).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:[],attempt:null}
})
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:[],attempt:null}
})
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(nativePay).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:[],attempt:null}
})
const {wrapper,vm}=await open();await vm.prepareEcashPay();await vm.cancelCashuPurchase()
expect(vm.hasBlockingCashuPurchase).toBe(true)
await vm.payWithLightning();expect(nativePay).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:[],attempt:null}
})
const {wrapper,vm}=await open();await vm.payWithLightning()
expect(nativePay).not.toHaveBeenCalled()
expect(vi.mocked(rpcClient.call).mock.calls.some(([call])=>call.method==='content.invoice-create')).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(nativePay).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:[],attempt:null}
})
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(nativePay).not.toHaveBeenCalled()
wrapper.unmount()
})
})
describe('External invoice recovery retains terminal settlement', () => {
it('reopening a paid invoice QR never downgrades the receipt or requests another invoice', async () => {
const settled = { bolt11: 'ln-original', payment_hash: hash, price_sats: 5, state: 'succeeded' }
localStorage.setItem(receiptKey, JSON.stringify(settled))
const { wrapper, vm } = await open()
await vm.payWithInvoice()
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject(settled)
expect(vm.lnReceipt.state).toBe('succeeded')
expect(vi.mocked(rpcClient.call).mock.calls.some(([v]) => v.method === 'content.invoice-create')).toBe(false)
expect(nativePay).not.toHaveBeenCalled()
wrapper.unmount()
})
})
describe('Durable external invoice ownership', () => {
it('recovers a browser-lost invoice from node storage and blocks other rails', async()=>{
const original=vi.mocked(rpcClient.call).getMockImplementation()!
vi.mocked(rpcClient.call).mockImplementation(async args=>args.method==='content.invoice-attempt'?{attempt:{operation_id:'11111111-1111-4111-8111-111111111111',price_sats:5,external_exposure:true,status:{payment_hash:hash,bolt11:'ln-original',state:'issued',can_switch_method:false,retired_unallocated:false}}}:original(args))
const {wrapper,vm}=await open();await flushPromises();await vm.prepareEcashPay();await vm.payOnchain()
expect(vm.hasBlockingLightningReceipt).toBe(true)
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({bolt11:'ln-original',external_exposure:true})
expect(vi.mocked(rpcClient.call).mock.calls.some(([v])=>['content.invoice-create','lnd.sendcoins','content.purchase'].includes(v.method))).toBe(false)
wrapper.unmount()
})
it('local LND failure cannot release an externally displayed invoice',async()=>{
localStorage.setItem(receiptKey,JSON.stringify({bolt11:'ln-external',payment_hash:hash,price_sats:5,origin:'native',external_exposure:true,state:'pending'}))
const original=vi.mocked(rpcClient.call).getMockImplementation()!
vi.mocked(rpcClient.call).mockImplementation(async args=>args.method==='lnd.paymentstatus'?{status:'failed'}:args.method==='content.invoice-status'?{paid:false,state:'open',can_switch_method:false,retired_unallocated:false}:original(args))
const {wrapper,vm}=await open();await vm.payWithLightning()
expect(vm.hasBlockingLightningReceipt).toBe(true);expect(nativePay).not.toHaveBeenCalled()
wrapper.unmount()
})
it('settlement wins a cancellation reply and keeps paid-file recovery',async()=>{
localStorage.setItem(receiptKey,JSON.stringify({operation_id:'11111111-1111-4111-8111-111111111111',bolt11:'ln-external',payment_hash:hash,price_sats:5,origin:'external',external_exposure:true,state:'pending'}))
const original=vi.mocked(rpcClient.call).getMockImplementation()!
vi.mocked(rpcClient.call).mockImplementation(async args=>args.method==='content.invoice-cancel'?{paid:true,state:'settled',can_switch_method:false,retired_unallocated:false}:original(args))
const {wrapper,vm}=await open();await vm.cancelExternalInvoice()
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({state:'succeeded'})
expect(vm.hasBlockingLightningReceipt).toBe(true);expect(nativePay).not.toHaveBeenCalled()
wrapper.unmount()
})
})
describe('Succeeded invoice reconciliation',()=>{
it('retains a succeeded browser receipt when node invoice metadata has not reached settled yet',async()=>{
const operation='11111111-1111-4111-8111-111111111111'
localStorage.setItem(receiptKey,JSON.stringify({operation_id:operation,payment_hash:hash,price_sats:5,origin:'native',external_exposure:false,state:'succeeded'}))
const original=vi.mocked(rpcClient.call).getMockImplementation()!
vi.mocked(rpcClient.call).mockImplementation(async args=>args.method==='content.invoice-attempt'?{attempt:{operation_id:operation,price_sats:5,external_exposure:false,status:{payment_hash:hash,bolt11:null,state:'issued',can_switch_method:false,retired_unallocated:false}}}:original(args))
const {wrapper,vm}=await open()
expect(vm.lnReceipt.state).toBe('succeeded')
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({state:'succeeded'})
expect(vm.hasBlockingLightningReceipt).toBe(true)
expect(nativePay).not.toHaveBeenCalled()
wrapper.unmount()
})
it('restores node-proven native success after browser storage is lost',async()=>{
const original=vi.mocked(rpcClient.call).getMockImplementation()!
vi.mocked(rpcClient.call).mockImplementation(async args=>args.method==='content.invoice-attempt'?{attempt:{operation_id:'11111111-1111-4111-8111-111111111111',price_sats:5,external_exposure:false,native_succeeded:true,status:{payment_hash:hash,bolt11:null,state:'issued',can_switch_method:false,retired_unallocated:false}}}:original(args))
const {wrapper,vm}=await open()
expect(vm.lnReceipt.state).toBe('succeeded')
expect(vm.hasBlockingLightningReceipt).toBe(true)
expect(nativePay).not.toHaveBeenCalled()
wrapper.unmount()
})
})
describe('Damaged supplemental invoice receipt',()=>{
it('reconciles only from an authoritative saved node operation and preserves the damaged bytes',async()=>{
localStorage.setItem(receiptKey,'{damaged receipt')
const original=vi.mocked(rpcClient.call).getMockImplementation()!
vi.mocked(rpcClient.call).mockImplementation(async args=>args.method==='content.invoice-attempt'?{attempt:{operation_id:'11111111-1111-4111-8111-111111111111',price_sats:5,external_exposure:true,status:{payment_hash:hash,bolt11:'ln-original',state:'issued',can_switch_method:false,retired_unallocated:false}}}:original(args))
const {wrapper,vm}=await open()
expect(localStorage.getItem(`${receiptKey}:unreadable`)).toBe('{damaged receipt')
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({payment_hash:hash,state:'pending',external_exposure:true})
expect(vm.lnReceiptReadError).toBe(false)
expect(vm.hasBlockingLightningReceipt).toBe(true)
expect(nativePay).not.toHaveBeenCalled()
wrapper.unmount()
})
it('retains damaged bytes and blocks replacement if the original node lookup fails',async()=>{
localStorage.setItem(receiptKey,'{damaged receipt')
const original=vi.mocked(rpcClient.call).getMockImplementation()!
vi.mocked(rpcClient.call).mockImplementation(async args=>{if(args.method==='content.invoice-attempt')throw Error('Original journal unavailable');return original(args)})
const {wrapper,vm}=await open();await vm.payWithLightning()
expect(localStorage.getItem(receiptKey)).toBe('{damaged receipt')
expect(localStorage.getItem(`${receiptKey}:unreadable`)).toBeNull()
expect(vm.hasBlockingLightningReceipt).toBe(true)
expect(nativePay).not.toHaveBeenCalled()
wrapper.unmount()
})
})
it('recovers a saved incomplete invoice request without paying or exposing its BOLT11',async()=>{
let recovered=false
const operation='11111111-1111-4111-8111-111111111111'
const original=vi.mocked(rpcClient.call).getMockImplementation()!
vi.mocked(rpcClient.call).mockImplementation(async args=>{
if(args.method==='content.invoice-attempt')return {attempt:{operation_id:operation,price_sats:5,external_exposure:false,status:recovered?{payment_hash:hash,bolt11:null,state:'issued',can_switch_method:false,retired_unallocated:false}:null}}
if(args.method==='content.invoice-create'){recovered=true;return {operation_id:operation,state:'open',payment_hash:hash}}
return original(args)
})
const {wrapper,vm}=await open();expect(vm.lnReceiptReadError).toBe(true)
await vm.recoverOriginalInvoice()
expect(vm.lnReceiptReadError).toBe(false)
expect(vi.mocked(rpcClient.call).mock.calls.find(([call])=>call.method==='content.invoice-create')![0].params).toEqual({onion:'peer.onion',content_id:item.id,operation_id:operation,external_exposure:false})
expect(nativePay).not.toHaveBeenCalled();expect(vm.invoiceData).toBeNull()
expect(vm.lnReceipt).toMatchObject({operation_id:operation,state:'pending',external_exposure:false})
wrapper.unmount()
})
describe('Explicit retry of a confirmed native-only failure',()=>{
const oldOperation='11111111-1111-4111-8111-111111111111'
const nextOperation='22222222-2222-4222-8222-222222222222'
const nextHash='b'.repeat(64)
it('does not retry on reopen; the explicit action pays only the new node-owned operation',async()=>{
localStorage.setItem(receiptKey,JSON.stringify({operation_id:oldOperation,payment_hash:hash,price_sats:5,origin:'native',external_exposure:false,state:'failed'}))
const original=vi.mocked(rpcClient.call).getMockImplementation()!
vi.mocked(rpcClient.call).mockImplementation(async args=>args.method==='content.invoice-retry-native'?{operation_id:nextOperation,payment_hash:nextHash,price_sats:5,external_exposure:false,state:'open'}:original(args))
download.mockResolvedValue({error:'Delivery still pending'})
const {wrapper,vm}=await open()
expect(nativePay).not.toHaveBeenCalled()
expect(vi.mocked(rpcClient.call).mock.calls.some(([call])=>call.method==='content.invoice-retry-native')).toBe(false)
await vm.retryNativeLightning()
expect(vi.mocked(rpcClient.call).mock.calls.find(([call])=>call.method==='content.invoice-retry-native')![0].params).toMatchObject({operation_id:oldOperation,price_sats:5})
expect(vi.mocked(rpcClient.call).mock.calls.filter(([call])=>call.method==='content.invoice-pay').map(([call])=>call.params)).toEqual([{onion:'peer.onion',content_id:item.id,operation_id:nextOperation}])
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({operation_id:nextOperation,payment_hash:nextHash,state:'succeeded'})
wrapper.unmount()
})
it('does not create a native retry for an invoice exposed to another wallet',async()=>{
localStorage.setItem(receiptKey,JSON.stringify({operation_id:oldOperation,payment_hash:hash,price_sats:5,bolt11:'ln-external',origin:'external',external_exposure:true,state:'failed'}))
const {wrapper,vm}=await open();await vm.retryNativeLightning()
expect(vi.mocked(rpcClient.call).mock.calls.some(([call])=>call.method==='content.invoice-retry-native')).toBe(false)
expect(nativePay).not.toHaveBeenCalled();wrapper.unmount()
})
it('a late failed reply from the old attempt cannot overwrite a replacement created in another window',async()=>{
let resolve!: (value:unknown)=>void
nativePay.mockImplementation(async()=>await new Promise(value=>{resolve=value}))
const {wrapper,vm}=await open();const old=vm.payWithLightning();await flushPromises()
expect(resolve).toBeTypeOf('function')
const replacement={operation_id:nextOperation,payment_hash:nextHash,price_sats:5,origin:'native',external_exposure:false,state:'pending'}
localStorage.setItem(receiptKey,JSON.stringify(replacement))
resolve({status:'failed',failure_reason:'Old attempt failed'})
await old
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toEqual(replacement)
expect(vm.lnReceipt.operation_id).toBe(nextOperation)
expect(vm.lnError).not.toContain('Old attempt failed')
wrapper.unmount()
})
})
describe('Supported peer-file ecash choices', () => {
it('never selects or spends Fedimint even when funded, including stale callbacks', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'wallet.ecash-balance') return { cashu_sats: 0, fedimint_sats: 1000 }
return { items: [], attempts: [], attempt: null }
})
const { wrapper, vm } = await open()
vm.openPayModal({ ...item, access: { paid: { price_sats: 5, accepted: ['fedimint'] } } })
await flushPromises()
expect(wrapper.text()).toContain('Fedimint file purchases are temporarily unavailable')
expect(wrapper.findAll('button').some(button => button.text().includes('Pay from this node’s ecash wallet'))).toBe(false)
await vm.prepareEcashPay()
expect(vm.ecashPlan.chosen).toBeNull()
expect(vm.ecashPlan.fedimint).toBe(1000)
await vm.selectEcashBackend('fedimint')
expect(vm.ecashPlan.chosen).toBeNull()
vm.ecashPlan.chosen = 'fedimint'
await vm.confirmEcashPay()
expect(vm.purchaseError).toContain('Fedimint file purchases are temporarily unavailable')
expect(vi.mocked(rpcClient.call).mock.calls.some(([call]) => ['content.download-peer-paid', 'content.purchase'].includes(call.method))).toBe(false)
wrapper.unmount()
})
it('does not offer or spend Ark even when its wallet alone covers the file', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'wallet.ecash-balance') return { cashu_sats: 0, fedimint_sats: 0, ark_sats: 1000 }
return { items: [], attempts: [], attempt: null }
})
const { wrapper, vm } = await open()
vm.openPayModal({ ...item, access: { paid: { price_sats: 5, accepted: ['fedimint'] } } })
await flushPromises()
await vm.prepareEcashPay()
expect(vm.ecashPlan.chosen).toBeNull()
expect(vm.ecashPlan.total).toBe(0)
expect(vm.purchaseError).toContain('Ark cannot pay for peer files yet')
expect(wrapper.findAll('button').some(button => button.text().includes('Ark'))).toBe(false)
await vm.selectEcashBackend('ark')
expect(vm.ecashPlan.chosen).toBeNull()
// A stale or injected unsupported selection must also fail before RPC.
vm.ecashPlan.chosen = 'ark'
await vm.confirmEcashPay()
expect(vi.mocked(rpcClient.call).mock.calls.some(([call]) => call.method === 'content.download-peer-paid')).toBe(false)
wrapper.unmount()
})
})
describe('Unknown on-chain verification', () => {
it('shows verification errors without downloading, paying or creating another address', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'content.onchain-recover') throw Error('Exact outputs unavailable; do not pay again.')
return { items: [], attempts: [], attempt: null }
})
const { wrapper, vm } = await open()
await vm.pollOnchain('original-address')
expect(vm.lnError).toContain('do not pay again')
const calls = vi.mocked(rpcClient.call).mock.calls.map(([call]) => call.method)
expect(calls).not.toContain('content.download-peer-onchain')
expect(calls).not.toContain('content.request-onchain')
expect(calls).not.toContain('lnd.sendcoins')
wrapper.unmount()
})
})
describe('Original on-chain transaction confirmation and callback ownership',()=>{
const chainItem={...item,access:{paid:{price_sats:546,accepted:['onchain','lightning','ecash']}}}
const prepared={...onchainFixture,phase:'plan_prepared',fee_sats:142,max_fee_sats:1000,plan_sha256:'c'.repeat(64),template_sha256:null}
it('reviews the saved fee before a separate confirmation and never calls sendcoins',async()=>{
vi.mocked(rpcClient.call).mockImplementation(async({method})=>{
if(method==='content.onchain-create')return {...onchainFixture,phase:'offer_prepared'}
if(method==='content.onchain-prepare')return prepared
if(method==='content.onchain-pay')throw Error('Lost publish reply; recover original')
return {items:[],attempts:[],attempt:null}
})
const {wrapper,vm}=await open();vm.openPayModal(chainItem);await flushPromises()
await vm.payOnchain()
expect(vm.onchainAttempt.fee_sats).toBe(142)
expect(vi.mocked(rpcClient.call).mock.calls.some(([c])=>c.method==='content.onchain-pay')).toBe(false)
await vm.payOnchain()
expect(vi.mocked(rpcClient.call).mock.calls.find(([c])=>c.method==='content.onchain-pay')![0].params).toMatchObject({operation_id:prepared.operation_id,plan_sha256:prepared.plan_sha256})
expect(vi.mocked(rpcClient.call).mock.calls.some(([c])=>c.method==='lnd.sendcoins')).toBe(false)
expect(vm.onchainAttempt.operation_id).toBe(prepared.operation_id);wrapper.unmount()
})
it('does not prepare or send after a delayed create reply outlives its modal',async()=>{
let finish!:(v:unknown)=>void
vi.mocked(rpcClient.call).mockImplementation(async({method})=>method==='content.onchain-create'?await new Promise(resolve=>{finish=resolve}):{items:[],attempts:[],attempt:null})
const {wrapper,vm}=await open();vm.openPayModal(chainItem);await flushPromises()
const pending=vm.payOnchain();await flushPromises();expect(finish).toBeTypeOf('function')
vm.closePayModal();vm.openPayModal({...chainItem,id:'other'});await flushPromises();finish(onchainFixture);await pending
expect(vm.onchainAttempt).toBeNull()
expect(vi.mocked(rpcClient.call).mock.calls.some(([c])=>['content.onchain-prepare','content.onchain-pay','lnd.sendcoins'].includes(c.method))).toBe(false)
wrapper.unmount()
})
it('reloads original state and blocks replacement rails without paying automatically',async()=>{
vi.mocked(rpcClient.call).mockImplementation(async({method})=>method==='content.onchain-attempt'?{attempt:prepared}:{items:[],attempts:[],attempt:null})
const {wrapper,vm}=await open();vm.openPayModal(chainItem);await flushPromises()
expect(vm.onchainAttempt.operation_id).toBe(prepared.operation_id)
await vm.payWithLightning();await vm.prepareEcashPay()
expect(vi.mocked(rpcClient.call).mock.calls.some(([c])=>['content.invoice-create','content.purchase','content.onchain-pay','lnd.sendcoins'].includes(c.method))).toBe(false)
wrapper.unmount()
})
})
describe('Cancel only provably unallocated on-chain operations',()=>{
const unallocated={...onchainFixture,phase:'address_requested',network:null}
const retired={...unallocated,retired_unallocated:true,can_switch_method:true}
it('unlocks other rails only after matching terminal seller acknowledgement',async()=>{
vi.mocked(rpcClient.call).mockImplementation(async({method})=>{
if(method==='content.onchain-attempt')return {attempt:unallocated}
if(method==='content.onchain-cancel')return retired
if(method==='wallet.ecash-balance')return {cashu_sats:10,fedimint_sats:0}
return {items:[],attempts:[],attempt:null}
})
const {wrapper,vm}=await open()
expect(vm.onchainAttempt.operation_id).toBe(unallocated.operation_id)
await vm.cancelOriginalOnchain()
expect(vm.onchainAttempt).toBeNull()
await vm.prepareEcashPay()
expect(vi.mocked(rpcClient.call).mock.calls.some(([c])=>c.method==='wallet.ecash-balance')).toBe(true)
expect(vi.mocked(rpcClient.call).mock.calls.some(([c])=>['content.onchain-create','content.onchain-pay','lnd.sendcoins'].includes(c.method))).toBe(false)
wrapper.unmount()
})
it('keeps an unknown allocation blocked after cancellation fails',async()=>{
vi.mocked(rpcClient.call).mockImplementation(async({method})=>{
if(method==='content.onchain-attempt')return {attempt:unallocated}
if(method==='content.onchain-cancel')throw Error('Original address allocation was dispatched; recover it')
return {items:[],attempts:[],attempt:null}
})
const {wrapper,vm}=await open();await vm.cancelOriginalOnchain();await vm.payWithLightning()
expect(vm.onchainAttempt.operation_id).toBe(unallocated.operation_id)
expect(vi.mocked(rpcClient.call).mock.calls.some(([c])=>c.method==='content.invoice-create')).toBe(false)
wrapper.unmount()
})
it('cannot clear another selection after an old cancellation reply arrives',async()=>{
let finish!:(v:unknown)=>void
const other={...unallocated,operation_id:'33333333-3333-4333-8333-333333333333'}
vi.mocked(rpcClient.call).mockImplementation(async({method,params})=>{
if(method==='content.onchain-attempt')return {attempt:(params as {content_id:string}).content_id==='other'?other:unallocated}
if(method==='content.onchain-cancel')return await new Promise(resolve=>{finish=resolve})
return {items:[],attempts:[],attempt:null}
})
const {wrapper,vm}=await open();const cancel=vm.cancelOriginalOnchain();await flushPromises()
expect(finish).toBeTypeOf('function');vm.closePayModal();vm.openPayModal({...item,id:'other'});await flushPromises()
finish(retired);await cancel
expect(vm.onchainAttempt.operation_id).toBe(other.operation_id)
wrapper.unmount()
})
})
describe('Unallocated two-phase on-chain review',()=>{
it('lets an insufficient-funds review cancel before any Pay or address exposure',async()=>{
const offer={...onchainFixture,phase:'offer_prepared'}
vi.mocked(rpcClient.call).mockImplementation(async({method})=>{
if(method==='content.onchain-create')return offer
if(method==='content.onchain-prepare')throw Error('Insufficient confirmed funds; no seller address or inputs allocated')
if(method==='content.onchain-cancel')return {...offer,retired_unallocated:true,can_switch_method:true}
if(method==='wallet.ecash-balance')return {cashu_sats:1000,fedimint_sats:0}
return {items:[],attempts:[],attempt:null}
})
const {wrapper,vm}=await open();vm.openPayModal({...item,access:{paid:{price_sats:546,accepted:['onchain','ecash']}}});await flushPromises()
await vm.payOnchain();expect(vm.lnError).toContain('Insufficient confirmed funds')
expect(vm.onchainAttempt.operation_id).toBe(offer.operation_id)
await vm.cancelOriginalOnchain();expect(vm.onchainAttempt).toBeNull()
await vm.prepareEcashPay()
const calls=vi.mocked(rpcClient.call).mock.calls.map(([call])=>call.method)
expect(calls).toContain('wallet.ecash-balance')
for(const forbidden of ['content.onchain-pay','content.onchain-expose','lnd.sendcoins'])expect(calls).not.toContain(forbidden)
wrapper.unmount()
})
})