Integrate two-phase on-chain purchase recovery

This commit is contained in:
archipelago
2026-10-07 07:49:02 -04:00
parent 558f097fd6
commit 6547ae05fa
18 changed files with 6084 additions and 113 deletions
@@ -0,0 +1,27 @@
import { describe,it,expect } from 'vitest'
import { parseOnchainAttempt } from '../peerOnchainPurchase'
const original={operation_id:'11111111-1111-4111-8111-111111111111',price_sats:546,phase:'template_prepared',network:'mainnet',external_exposure:false,address:null,fee_sats:142,max_fee_sats:1000,template_sha256:'a'.repeat(64),plan_sha256:null,txid:null,paid:false,change_allocation_ambiguous:false,can_switch_method:false,retired_unallocated:false}
describe('durable on-chain owner state',()=>{
it('preserves original amount and fee for explicit confirmation',()=>expect(parseOnchainAttempt(original)).toEqual(original))
it('rejects unknown state and incoherent exposure or fee metadata',()=>{
for(const value of [{},{...original,can_switch_method:true},{...original,address:'bc1hidden'},{...original,external_exposure:true},{...original,fee_sats:1001},{...original,price_sats:545},{...original,template_sha256:'bad'},{...original,phase:'failed'}])expect(()=>parseOnchainAttempt(value)).toThrow()
})
it('does not equate allocation ambiguity with permission to switch',()=>{
const result=parseOnchainAttempt({...original,phase:'quoted',fee_sats:null,max_fee_sats:null,template_sha256:null,change_allocation_ambiguous:true})
expect(result.can_switch_method).toBe(false);expect(result.change_allocation_ambiguous).toBe(true)
})
})
it('accepts only a coherent terminal unallocated acknowledgement for switching',()=>{
const retired={...original,phase:'address_requested',network:null,fee_sats:null,max_fee_sats:null,template_sha256:null,retired_unallocated:true,can_switch_method:true}
expect(parseOnchainAttempt(retired).can_switch_method).toBe(true)
for(const value of [{...retired,address:'bc1issued'},{...retired,external_exposure:true},{...retired,paid:true},{...retired,change_allocation_ambiguous:true},{...retired,phase:'funded'},{...retired,txid:'b'.repeat(64)}])expect(()=>parseOnchainAttempt(value)).toThrow()
})
it('requires original plan confirmation and permits only unallocated plan retirement',()=>{
const plan={...original,phase:'plan_prepared',template_sha256:null,plan_sha256:'c'.repeat(64)}
expect(parseOnchainAttempt(plan).plan_sha256).toBe(plan.plan_sha256)
expect(()=>parseOnchainAttempt({...plan,plan_sha256:null})).toThrow()
expect(parseOnchainAttempt({...plan,can_switch_method:true,retired_unallocated:true}).can_switch_method).toBe(true)
expect(()=>parseOnchainAttempt({...plan,phase:'plan_lease_dispatched',can_switch_method:true,retired_unallocated:true})).toThrow()
})
@@ -0,0 +1,38 @@
/** Durable owner-node state; never infer an unpaid address from a browser timeout. */
export interface OnchainAttempt {
operation_id: string
price_sats: number
phase: 'address_requested' | 'offer_prepared' | 'plan_prepared' | 'plan_lease_dispatched' | 'inputs_leased' | 'address_allocation_dispatched' | 'quoted' | 'template_prepared' | 'lease_dispatched' | 'funding_dispatched' | 'funded' | 'signing_dispatched' | 'signed' | 'broadcast_dispatched' | 'published'
network: 'mainnet' | 'testnet' | 'signet' | 'regtest' | null
external_exposure: boolean
address: string | null
fee_sats: number | null
max_fee_sats: number | null
template_sha256: string | null
plan_sha256: string | null
txid: string | null
paid: boolean
change_allocation_ambiguous: boolean
can_switch_method: boolean
retired_unallocated: boolean
}
const phases = new Set(['address_requested','offer_prepared','plan_prepared','plan_lease_dispatched','inputs_leased','address_allocation_dispatched','quoted','template_prepared','lease_dispatched','funding_dispatched','funded','signing_dispatched','signed','broadcast_dispatched','published'])
export function parseOnchainAttempt(value: unknown): OnchainAttempt {
if (!value || typeof value !== 'object') throw Error('Original on-chain state is unavailable; do not pay again')
const v = { plan_sha256: null, ...value } as Record<string,unknown>
const integer = (n:unknown) => typeof n === 'number' && Number.isSafeInteger(n) && n >= 0
const hex = (s:unknown) => typeof s === 'string' && /^[0-9a-f]{64}$/.test(s)
if (typeof v.operation_id !== 'string' || !/^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(v.operation_id)
|| !integer(v.price_sats) || Number(v.price_sats)<546 || !phases.has(String(v.phase))
|| ![null,'mainnet','testnet','signet','regtest'].includes(v.network as string|null)
|| typeof v.external_exposure !== 'boolean' || typeof v.paid !== 'boolean'
|| typeof v.change_allocation_ambiguous !== 'boolean' || typeof v.retired_unallocated !== 'boolean' || v.can_switch_method !== v.retired_unallocated
|| !(v.address === null || typeof v.address === 'string' && v.address.length>0)
|| !(v.fee_sats === null || integer(v.fee_sats)) || !(v.max_fee_sats === null || integer(v.max_fee_sats))
|| !(v.plan_sha256 === null || hex(v.plan_sha256)) || !(v.template_sha256 === null || hex(v.template_sha256)) || !(v.txid === null || hex(v.txid))) throw Error('Original on-chain state is invalid; do not pay again')
if (v.external_exposure && !v.address || !v.external_exposure && v.address !== null
|| (v.template_sha256 !== null || v.plan_sha256 !== null) && (v.fee_sats === null || v.max_fee_sats === null || Number(v.fee_sats)>Number(v.max_fee_sats))) throw Error('Original on-chain payment terms changed')
if (['plan_prepared','plan_lease_dispatched','inputs_leased'].includes(String(v.phase)) && (v.plan_sha256 === null || v.network === null || v.external_exposure || v.address !== null)) throw Error('Original funding plan is incomplete')
if (v.retired_unallocated && (!['address_requested','offer_prepared','plan_prepared'].includes(String(v.phase)) || v.external_exposure || v.paid || v.address !== null || v.template_sha256 !== null || v.txid !== null || v.change_allocation_ambiguous)) throw Error('Retired on-chain operation contains payment liability')
return v as unknown as OnchainAttempt
}
+116 -95
View File
@@ -454,11 +454,17 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
<span>
<span class="block text-base text-white">{{ onchainPaying ? 'Sending…' : 'Pay on-chain from my node' }}</span>
<span class="block text-sm text-white/50">Sends Bitcoin on-chain from your node’s wallet (slower)</span>
<span class="block text-base text-white">{{ onchainPaying ? 'Recovering original transaction…' : onchainAttempt?.paid ? 'Recover original download' : (onchainAttempt?.plan_sha256 || onchainAttempt?.template_sha256) ? 'Confirm / resume original transaction' : onchainAttempt?.external_exposure ? 'Check original Bitcoin payment' : 'Review on-chain payment' }}</span>
<span class="block text-sm text-white/50">Reviews the current Fast fee before sending the saved transaction</span>
</span>
</button>
<label v-if="acceptsMethod(payItem.access, 'onchain') && !(onchainAttempt?.plan_sha256 || onchainAttempt?.template_sha256)" class="block text-sm text-white/70">Maximum network fee (sats)
<input v-model="onchainFeeCap" type="number" min="1" step="1" class="mt-1 w-full min-h-11 rounded-xl bg-white/10 px-3" :disabled="paymentActionBusy" />
</label>
<p v-if="(onchainAttempt?.plan_sha256 || onchainAttempt?.template_sha256)" class="text-sm text-white/70 break-words">Original Bitcoin payment: {{ onchainAttempt.price_sats }} sats + {{ onchainAttempt.fee_sats }} sats network fee · {{ onchainAttempt.network }}. Confirming resumes this same funding plan; the seller address is allocated only after Pay.</p>
<button v-if="onchainAttempt && ['address_requested', 'offer_prepared', 'plan_prepared'].includes(onchainAttempt.phase) && !onchainAttempt.change_allocation_ambiguous" type="button" class="glass-button w-full min-h-11 rounded-xl px-3 py-2" :disabled="paymentActionBusy" @click="cancelOriginalOnchain">Cancel if no address was issued</button>
<p v-if="onchainAttempt?.change_allocation_ambiguous" class="text-sm text-amber-300">The original change-address reply was lost. This saved operation needs recovery; no replacement address or payment will be created.</p>
<p v-if="lnError" class="text-xs text-red-400 px-1">{{ lnError }}</p>
</div>
@@ -608,6 +614,7 @@
</template>
<script setup lang="ts">
import { parseOnchainAttempt, type OnchainAttempt } from '@/composables/peerOnchainPurchase'
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'
@@ -893,10 +900,11 @@ async function lookupNodeInvoice(onion:string,item:CatalogItem,generation:number
keepReceipt(onion,item.id,receipt,selected,previous?.state==='failed'?previous.operation_id:undefined);lnReceiptReadError.value=false
}catch(error){if(selected()){lnReceiptReadError.value=true;lnError.value=error instanceof Error?error.message:'Could not verify original invoice; do not pay again'}}
}
async function permitFreshOtherRail(item: CatalogItem, onion: string, recoverInvoice = false) {
async function permitFreshOtherRail(item: CatalogItem, onion: string, recoverInvoice = false, recoverOnchain = false) {
const generation=paymentGeneration.value
await cashuLookup
if (paymentGeneration.value!==generation || !activePaymentMatches(onion,item.id)) return false
if (!recoverOnchain && (onchainAttempt.value || onchainLookupError.value)) { lnError.value='Recover the original on-chain purchase before choosing another method.'; return false }
if (hasBlockingCashuPurchase.value) { lnError.value='Recover or cancel the saved Cashu purchase before choosing another method.'; return false }
if (!recoverInvoice && hasBlockingLightningReceipt.value) {lnError.value='Recover or cancel the original invoice before choosing another method.';return false}
return true
@@ -913,6 +921,9 @@ const invoiceError = ref('')
const invoiceCopied = ref(false)
const invoiceOperationId = ref<string | null>(null)
// On-chain QR (pay the seller's address from any external wallet).
const onchainAttempt = ref<OnchainAttempt | null>(null)
const onchainLookupError = ref(false)
const onchainFeeCap = ref('1000')
const onchainData = ref<{ address: string; amount_sats: number } | null>(null)
const onchainQr = ref('')
const onchainWaiting = ref(false)
@@ -1245,6 +1256,8 @@ function openPayModal(item: CatalogItem) {
invoiceError.value = ''
invoiceCopied.value = false
invoiceOperationId.value = null
onchainAttempt.value = null
onchainLookupError.value = false
onchainData.value = null
onchainQr.value = ''
onchainWaiting.value = false
@@ -1259,7 +1272,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 = Promise.all([lookupCashuPurchase(props.peerId || currentPeer.value?.onion || '', item, paymentGeneration.value),lookupNodeInvoice(props.peerId || currentPeer.value?.onion || '', item, paymentGeneration.value)]).then(()=>undefined)
cashuLookup = Promise.all([lookupCashuPurchase(props.peerId || currentPeer.value?.onion || '', item, paymentGeneration.value),lookupNodeInvoice(props.peerId || currentPeer.value?.onion || '', item, paymentGeneration.value),lookupOnchainPurchase(props.peerId || currentPeer.value?.onion || '', item, paymentGeneration.value)]).then(()=>undefined)
}
function closePayModal() {
@@ -1320,7 +1333,7 @@ function selectQrTab(tab: 'onchain' | 'lightning') {
loadOnchainQr()
} else if (onchainData.value && !onchainPaying.value) {
onchainPaying.value = true
pollOnchain(onchainData.value.address)
onchainAttempt.value && pollOnchain(onchainAttempt.value.operation_id)
}
} else {
if (onchainPollTimer) { clearTimeout(onchainPollTimer); onchainPollTimer = null }
@@ -1338,35 +1351,50 @@ function selectQrTab(tab: 'onchain' | 'lightning') {
* `bitcoin:` QR for any external wallet, and poll the seller until the payment
* lands, then release the file (the address is the gate token).
*/
async function loadOnchainQr() {
if (hasBlockingLightningReceipt.value) return
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
onchainError.value = ''
onchainData.value = null
onchainQr.value = ''
onchainWaiting.value = true
async function lookupOnchainPurchase(onion: string, item: CatalogItem, generation: number) {
const selected=()=>generation===paymentGeneration.value && activePaymentMatches(onion,item.id)
try {
const req = await rpcClient.call<{ address?: string; amount_sats?: number; error?: string }>({ method: 'content.request-onchain', params: { onion, content_id: item.id }, timeout: 45000, maxRetries: 1 })
if (!req?.address || !req?.amount_sats) throw new Error(req?.error || 'The seller could not provide an on-chain address.')
if (!Number.isSafeInteger(req.amount_sats) || req.amount_sats !== getItemPrice(item.access) || req.amount_sats < 546) throw new Error('The seller changed the payment amount. Refresh the file before paying.')
// A closed modal has never displayed this address: do not expose a late QR.
if (!paymentOperations.selected(operation)) return
let image = ''
try { image = await QRCode.toDataURL(`bitcoin:${req.address}?amount=${(req.amount_sats / 1e8).toFixed(8)}`, { margin: 1, width: 240 }) } catch { /* raw address is available */ }
if (!paymentOperations.selected(operation)) return
onchainData.value = { address: req.address, amount_sats: req.amount_sats }
onchainQr.value = image
onchainPaying.value = true
void pollOnchain(req.address)
} catch (error) {
if (paymentOperations.selected(operation)) onchainError.value = error instanceof Error ? error.message : 'Could not request an on-chain address'
} finally { if (paymentOperations.finish(operation)) onchainWaiting.value = false }
const result=await rpcClient.call<{attempt?:unknown}>({method:'content.onchain-attempt',params:{onion,content_id:item.id},timeout:15000,maxRetries:1})
if(!selected()) return
if(!result || !Object.prototype.hasOwnProperty.call(result,'attempt')) throw Error('Could not verify original on-chain operation; do not pay again')
onchainAttempt.value=result.attempt===null?null:parseOnchainAttempt(result.attempt)
onchainLookupError.value=false
} catch(error) {if(selected()){onchainLookupError.value=true;lnError.value=error instanceof Error?error.message:'Could not verify original on-chain purchase'}}
}
async function cancelOriginalOnchain() {
const item=payItem.value,onion=props.peerId||currentPeer.value?.onion,original=onchainAttempt.value
if(!item||!onion||!original||paymentActionBusy.value)return
const operation=paymentOperations.begin('onchain-cancel',onion,item.id);if(!operation)return
try {
const result=parseOnchainAttempt(await rpcClient.call({method:'content.onchain-cancel',params:{onion,content_id:item.id,operation_id:original.operation_id},timeout:60000,maxRetries:1}))
if(result.operation_id!==original.operation_id || !result.retired_unallocated || !result.can_switch_method)throw Error('Seller has not confirmed that no address was allocated. Keep the original operation.')
if(!paymentOperations.selected(operation))return
onchainAttempt.value=null;onchainLookupError.value=false;lnError.value='Unallocated on-chain request canceled. You can choose another method.'
}catch(error){if(paymentOperations.selected(operation))lnError.value=error instanceof Error?error.message:'Original address allocation is unresolved; do not pay again'}
finally{paymentOperations.finish(operation)}
}
async function loadOnchainQr() {
const item=payItem.value,onion=props.peerId || currentPeer.value?.onion
if(!item || !onion || !await permitFreshOtherRail(item,onion,false,true)) return
if(!onchainAttempt.value && getItemPrice(item.access)<546){onchainError.value='On-chain payment requires at least 546 sats.';return}
const operation=paymentOperations.begin('onchain-qr',onion,item.id);if(!operation)return
onchainWaiting.value=true;onchainError.value=''
try {
if(onchainLookupError.value) throw Error('Recover original on-chain state before displaying another address')
const result=await rpcClient.call<unknown>({method:'content.onchain-expose',params:{onion,content_id:item.id,price_sats:onchainAttempt.value?.price_sats ?? getItemPrice(item.access),...(onchainAttempt.value?{operation_id:onchainAttempt.value.operation_id}:{})},timeout:60000,maxRetries:1})
const attempt=parseOnchainAttempt(result)
if(!onchainAttempt.value && attempt.price_sats!==getItemPrice(item.access))throw Error('The seller changed the payment amount; do not pay')
if(!paymentOperations.selected(operation))return
onchainAttempt.value=attempt.retired_unallocated?null:attempt
if(attempt.retired_unallocated){onchainPaying.value=false;return}
if(attempt.paid){await recoverOnchainDownload(item,onion,attempt,paymentGeneration.value);return}
if(!attempt.address || !attempt.external_exposure) throw Error('Original address allocation is unresolved; recover this operation without requesting another address')
const image=await QRCode.toDataURL(`bitcoin:${attempt.address}?amount=${(attempt.price_sats/1e8).toFixed(8)}`,{margin:1,width:240})
if(!paymentOperations.selected(operation))return
onchainData.value={address:attempt.address,amount_sats:attempt.price_sats};onchainQr.value=image
onchainPaying.value=true;void pollOnchain(attempt.operation_id)
}catch(error){if(paymentOperations.selected(operation))onchainError.value=error instanceof Error?error.message:'Recover the original on-chain operation; do not pay again'}
finally {if(paymentOperations.finish(operation))onchainWaiting.value=false}
}
async function copyOnchain() {
@@ -1382,71 +1410,63 @@ async function copyOnchain() {
} catch { /* clipboard denied */ }
}
/**
* Pay on-chain from THIS node's wallet: ask the seller for a fresh address +
* amount, broadcast with lnd.sendcoins, then poll the seller until it detects
* the payment and release the file (address is the gate token). Slower than LN
* because the seller waits for the tx to appear/confirm.
*/
/** Review and confirm the node's saved transaction; never call generic sendcoins. */
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)
if (!operation) return
onchainPaying.value = true
lnError.value = ''
let polling = false
const item=payItem.value,onion=props.peerId || currentPeer.value?.onion
if(!item || !onion || paymentActionBusy.value || !await permitFreshOtherRail(item,onion,false,true))return
if(!onchainAttempt.value && getItemPrice(item.access)<546){lnError.value='On-chain payment requires at least 546 sats.';return}
const operation=paymentOperations.begin('onchain-send',onion,item.id);if(!operation)return
const selected=()=>paymentOperations.selected(operation)
onchainPaying.value=true;lnError.value='';let polling=false
try {
const req = await rpcClient.call<{ address?: string; amount_sats?: number; error?: string }>({ method: 'content.request-onchain', params: { onion, content_id: item.id }, timeout: 45000, maxRetries: 1 })
if (!req?.address || !req?.amount_sats) throw new Error(req?.error || 'The seller could not provide an on-chain address.')
if (!Number.isSafeInteger(req.amount_sats) || req.amount_sats !== getItemPrice(item.access) || req.amount_sats < 546) throw new Error('The seller changed the payment amount. Refresh the file before paying.')
if (!paymentOperations.selected(operation)) return
const send = await rpcClient.call<{ txid?: string; error?: string }>({ method: 'lnd.sendcoins', params: { addr: req.address, amount: req.amount_sats }, timeout: 60000, maxRetries: 1 })
if (!send?.txid) throw new Error(send?.error || 'The on-chain result is unconfirmed. Check this wallet transaction before sending again.')
if (!paymentOperations.selected(operation)) return
polling = true
void pollOnchain(req.address)
} catch (error) {
if (paymentOperations.selected(operation)) lnError.value = error instanceof Error ? error.message : 'Could not confirm on-chain payment'
} finally {
if (paymentOperations.finish(operation) && !polling) onchainPaying.value = false
}
}
type OnchainPollScope = { item: CatalogItem; onion: string; address: string; generation: number }
function onchainScopeSelected(scope: OnchainPollScope) {
return paymentGeneration.value === scope.generation && activePaymentMatches(scope.onion, scope.item.id)
}
async function pollOnchain(address: string, original?: OnchainPollScope) {
const item = original?.item || payItem.value
const onion = original?.onion || props.peerId || currentPeer.value?.onion
if (!item || !onion) return
const scope = original || { item, onion, address, generation: paymentGeneration.value }
if (!onchainScopeSelected(scope)) return
try {
const res = await rpcClient.call<{ paid?: boolean; status?: string; error?: string }>({ method: 'content.onchain-status', params: { onion, content_id: item.id, address: scope.address }, timeout: 30000 })
if (!onchainScopeSelected(scope)) return
if (res?.error) lnError.value = res.error
if (res?.paid === true) {
const dl = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; mime_type?: string; error?: string }>({
method: 'content.download-peer-onchain', params: { onion, content_id: item.id, address: scope.address, filename: item.filename, price_sats: getItemPrice(item.access), cache_only: true }, timeout: 960000, maxRetries: 1,
})
if (!onchainScopeSelected(scope)) return
onchainPaying.value = false
if (dl?.data !== undefined || dl?.owned === true) {
openPurchased(item, dl.data, dl.mime_type, onion, dl.owned_content_id)
} else lnError.value = dl?.error || 'Payment is confirmed; delivery is still recoverable with this address.'
return
if(onchainLookupError.value)throw Error('Original on-chain lookup failed. Reopen this file to recover before paying.')
let attempt=onchainAttempt.value
if(attempt?.paid){await recoverOnchainDownload(item,onion,attempt,paymentGeneration.value);return}
if(attempt?.external_exposure) {polling=true;void pollOnchain(attempt.operation_id);return}
if(!attempt || attempt.phase==='address_requested') {
attempt=parseOnchainAttempt(await rpcClient.call({method:'content.onchain-create',params:{onion,content_id:item.id,price_sats:attempt?.price_sats ?? getItemPrice(item.access),...(attempt?{operation_id:attempt.operation_id}:{})},timeout:60000,maxRetries:1}))
if(!onchainAttempt.value && attempt.price_sats!==getItemPrice(item.access))throw Error('The seller changed the payment amount; do not pay')
if(!selected())return
onchainAttempt.value=attempt.retired_unallocated?null:attempt
if(attempt.retired_unallocated)return
if(attempt.phase==='address_requested')throw Error('The original seller offer is unresolved. Preserve this operation; no replacement address will be requested.')
}
} catch {
if (onchainScopeSelected(scope)) lnError.value = 'Payment verification is unavailable. Keep the original address and do not pay again.'
// Retry read-only status for the original item only.
}
if (onchainScopeSelected(scope) && onchainPaying.value) onchainPollTimer = setTimeout(() => pollOnchain(scope.address, scope), 5000)
if(!attempt.plan_sha256 && !attempt.template_sha256) {
const cap=Number(onchainFeeCap.value)
if(!Number.isSafeInteger(cap)||cap<=0)throw Error('Enter a positive whole-sat maximum fee')
attempt=parseOnchainAttempt(await rpcClient.call({method:'content.onchain-prepare',params:{onion,content_id:item.id,operation_id:attempt.operation_id,max_fee_sats:cap},timeout:60000,maxRetries:1}))
if(selected())onchainAttempt.value=attempt.retired_unallocated?null:attempt
return // A separate click confirms the actual saved fee and transaction.
}
if(!selected())return
attempt=parseOnchainAttempt(await rpcClient.call({method:'content.onchain-pay',params:{onion,content_id:item.id,operation_id:attempt.operation_id,template_sha256:attempt.template_sha256,plan_sha256:attempt.plan_sha256},timeout:120000,maxRetries:1}))
if(!selected())return
onchainAttempt.value=attempt.retired_unallocated?null:attempt;if(attempt.retired_unallocated)return;polling=true;void pollOnchain(attempt.operation_id)
}catch(error){if(selected())lnError.value=error instanceof Error?error.message:'Original on-chain result is unresolved; do not pay again'}
finally{if(paymentOperations.finish(operation)&&!polling)onchainPaying.value=false}
}
type OnchainPollScope={item:CatalogItem;onion:string;operationId:string;generation:number}
function onchainScopeSelected(scope:OnchainPollScope){return paymentGeneration.value===scope.generation&&activePaymentMatches(scope.onion,scope.item.id)}
async function recoverOnchainDownload(item:CatalogItem,onion:string,attempt:OnchainAttempt,generation:number) {
const dl=await rpcClient.call<{owned?:boolean;owned_content_id?:string;mime_type?:string}>({method:'content.onchain-download',params:{onion,content_id:item.id,operation_id:attempt.operation_id},timeout:960000,maxRetries:1})
if(generation!==paymentGeneration.value||!activePaymentMatches(onion,item.id))return
onchainPaying.value=false
if(dl.owned===true)openPurchased(item,undefined,dl.mime_type,onion,dl.owned_content_id)
else lnError.value='Payment is confirmed; recover its original download without paying again.'
}
async function pollOnchain(operationId:string,original?:OnchainPollScope) {
const item=original?.item||payItem.value,onion=original?.onion||props.peerId||currentPeer.value?.onion
if(!item||!onion)return
const scope=original||{item,onion,operationId,generation:paymentGeneration.value}
if(!onchainScopeSelected(scope))return
try {
const attempt=parseOnchainAttempt(await rpcClient.call({method:'content.onchain-recover',params:{onion,content_id:item.id,operation_id:scope.operationId},timeout:60000,maxRetries:1}))
if(!onchainScopeSelected(scope))return
onchainAttempt.value=attempt.retired_unallocated?null:attempt
if(attempt.retired_unallocated){onchainPaying.value=false;return}
if(attempt.paid){await recoverOnchainDownload(item,onion,attempt,scope.generation);return}
}catch(error){if(onchainScopeSelected(scope))lnError.value=error instanceof Error?error.message:'Original payment verification unavailable; do not pay again'}
if(onchainScopeSelected(scope)&&onchainPaying.value)onchainPollTimer=setTimeout(()=>pollOnchain(scope.operationId,scope),5000)
}
/** Spendable balance for a given ecash backend in the current plan. */
@@ -1469,6 +1489,7 @@ async function prepareEcashPay() {
await cashuLookup
if (paymentGeneration.value !== generation || !activePaymentMatches(onion, item.id)) return
if (hasBlockingLightningReceipt.value) {lnError.value='Recover or cancel the original invoice before choosing another method.';return}
if(onchainAttempt.value || onchainLookupError.value){lnError.value='Recover the original on-chain purchase first.';return}
const operation = paymentOperations.begin('prepare-ecash', onion, item.id)
if (!operation) return
const price = getItemPrice(item.access)
@@ -7,6 +7,7 @@ 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 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 }
@@ -39,16 +40,16 @@ beforeEach(() => {
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-status') return { paid: true }
if (method === 'content.download-peer-onchain') return { owned: true, mime_type: 'video/mp4', size_bytes: 200000000 }
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('bc1test')
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.download-peer-onchain')?.params).toMatchObject({ cache_only: true, address: 'bc1test', filename: 'bought.txt' })
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()
})
@@ -353,7 +354,7 @@ it('retains already dispatched Lightning evidence after unmount without opening
vm.openPayModal({ ...item, access: { paid: { price_sats: 545, accepted: ['onchain', 'lightning', 'ecash'] } } })
await vm.payOnchain()
await vm.loadOnchainQr()
expect(vi.mocked(rpcClient.call).mock.calls.some(([call]) => ['content.request-onchain', 'lnd.sendcoins'].includes(call.method))).toBe(false)
expect(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')
@@ -361,13 +362,13 @@ 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 }
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.request-onchain')).toHaveLength(1)
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)
@@ -593,7 +594,7 @@ describe('External invoice recovery retains terminal settlement', () => {
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}}}:original(args))
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})
@@ -603,7 +604,7 @@ describe('Durable external invoice ownership', () => {
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}:original(args))
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()
@@ -611,7 +612,7 @@ describe('Durable external invoice ownership', () => {
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}:original(args))
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()
@@ -625,7 +626,7 @@ describe('Succeeded invoice reconciliation',()=>{
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}}}:original(args))
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'})
@@ -635,7 +636,7 @@ describe('Succeeded invoice reconciliation',()=>{
})
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}}}:original(args))
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)
@@ -649,7 +650,7 @@ 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}}}:original(args))
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})
@@ -677,7 +678,7 @@ it('recovers a saved incomplete invoice request without paying or exposing its B
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}:null}}
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)
})
@@ -759,7 +760,7 @@ describe('Supported peer-file ecash choices', () => {
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-status') return { paid: false, status: 'unknown', error: 'Exact outputs unavailable; do not pay again.' }
if (method === 'content.onchain-recover') throw Error('Exact outputs unavailable; do not pay again.')
return { items: [], attempts: [], attempt: null }
})
const { wrapper, vm } = await open()
@@ -772,3 +773,112 @@ describe('Unknown on-chain verification', () => {
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()
})
})