From c4c9d8a94ae4856d8e81ca6faa6b1d0a4d871b60 Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 22 Aug 2026 16:55:12 +0200 Subject: [PATCH] fix: fence invoice payment operations --- src/lnurlcash/ops.pay.cases.ts | 75 ++++++++++++++++++++++++++++++++++ src/lnurlcash/ops/pay.ts | 31 +++++++------- 2 files changed, 90 insertions(+), 16 deletions(-) create mode 100644 src/lnurlcash/ops.pay.cases.ts diff --git a/src/lnurlcash/ops.pay.cases.ts b/src/lnurlcash/ops.pay.cases.ts new file mode 100644 index 0000000..5c27833 --- /dev/null +++ b/src/lnurlcash/ops.pay.cases.ts @@ -0,0 +1,75 @@ +import {describe, expect, it} from 'vitest' +import {noteK1} from 'lnurlcash-kit' + +import {payWithBearers} from './ops' +import {requiredValue} from './test-utils' +import {makeBearer, mint, secret} from './ops.testHarness' + +describe('payWithBearers', () => { + it('pays a bolt11 invoice by melting an exact note (settled)', async () => { + const instance = await mint() + const bearer = await makeBearer(instance, secret('30'), 21_000) + const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { + poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}, + }) + expect(result.outcome).toBe('settled') + expect(instance.state.noteState(secret('30'))).toBe('burned') + }) + + it('pays a Lightning Address by requesting an invoice first', async () => { + const payer = await mint() + const payee = await mint() + const bearer = await makeBearer(payer, secret('31'), 21_000) + const result = await payWithBearers([bearer], `mint@127.0.0.1:${payee.port}`, { + amountMsat: 21_000, + poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}, + }) + expect(result.outcome).toBe('settled') + expect(result.invoice).toMatch(/^lnbc/) + expect(payer.state.noteState(secret('31'))).toBe('burned') + }) + + it('carves the exact amount out of a larger note before melting', async () => { + const instance = await mint() + const bearer = await makeBearer(instance, secret('32'), 50_000) + const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { + poll: {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000}, + }) + expect(result.outcome).toBe('settled') + expect(instance.state.noteState(secret('32'))).toBe('burned') + expect(result.carve.consumed.map((entry) => entry.id)).toEqual([bearer.id]) + expect(result.carve.change?.amount).toBe(29_000) + const change = requiredValue(result.carve.change) + expect(instance.state.noteState(requiredValue(noteK1(change.url)))).toBe('outstanding') + }) + + it('classifies a failed melt as funds-returned once the note is spendable again', async () => { + const instance = await mint({meltAlwaysFails: true}) + const bearer = await makeBearer(instance, secret('33'), 21_000) + const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { + poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}, + }) + expect(result.outcome).toBe('failed-funds-returned') + expect(instance.state.noteState(secret('33'))).toBe('burned') + const returnedK1 = requiredValue(noteK1(result.carve.note.url)) + expect(instance.state.noteState(returnedK1)).toBe('outstanding') + expect(result.carve.note.amount).toBe(21_000) + }) + + it('classifies a never-settling melt as unknown-still-pending', async () => { + const instance = await mint({meltNeverSettles: true}) + const bearer = await makeBearer(instance, secret('34'), 21_000) + const result = await payWithBearers([bearer], 'lnbc210n1pjqrstuvwxyz', { + poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}, + }) + expect(result.outcome).toBe('unknown-still-pending') + expect(instance.state.noteState(secret('34'))).toBe('pending') + }) + + it('rejects an amountless or unreadable invoice instead of guessing', async () => { + const instance = await mint() + const bearer = await makeBearer(instance, secret('35'), 21_000) + await expect(payWithBearers([bearer], 'lnbc1pjqrstuvwxyz')).rejects.toThrow(/amount/) + await expect(payWithBearers([bearer], 'not-an-invoice')).rejects.toThrow(/not a valid/i) + }) +}) diff --git a/src/lnurlcash/ops/pay.ts b/src/lnurlcash/ops/pay.ts index c5fe5e7..c1fd878 100644 --- a/src/lnurlcash/ops/pay.ts +++ b/src/lnurlcash/ops/pay.ts @@ -16,14 +16,15 @@ import { resolveLnurlInput, rotateNote, sameInvoice, - withNewK1 + withNewK1, } from 'lnurlcash-kit' import type {LnurlcashOptions, MeltResult} from 'lnurlcash-kit' import type {Bearer, NewBearer} from '../types' import type {CarveResult} from './carve' import {ensureExactAmount} from './carve' import type {PollOptions} from './shared' -import {pollVerifyUntilSettled} from './shared' +import type {FundOperationOptions} from './shared' +import {assertFundOwner, pollVerifyUntilSettled} from './shared' export type PayOutcome = | 'settled' @@ -54,6 +55,7 @@ export type PayOptions = { poll?: PollOptions // kit transport overrides (fetch injection, timeouts) kit?: LnurlcashOptions + assertOwner?: () => void } // A melt's resolved promise only means the payment is in flight; the @@ -69,9 +71,9 @@ export type PayOptions = { export const payWithBearers = async ( bearers: Bearer[], input: string, - {amountMsat, poll = {}, kit = {}}: PayOptions = {} + {amountMsat, poll = {}, kit = {}, assertOwner}: PayOptions = {}, ): Promise => { - const options = kit + const options: FundOperationOptions = assertOwner ? {...kit, assertOwner} : kit let invoice: string let amount: number const trimmed = input.trim() @@ -79,7 +81,7 @@ export const payWithBearers = async ( const decoded = decodeBolt11AmountMsat(trimmed) if (decoded === null || decoded <= 0) { throw new Error( - 'Could not read this invoice\'s amount - amount-less invoices are not supported.' + "Could not read this invoice's amount - amount-less invoices are not supported.", ) } invoice = trimmed @@ -97,7 +99,7 @@ export const payWithBearers = async ( } const info = await fetchPayRequest(url, options) if (amountMsat < info.minSendable || amountMsat > info.maxSendable) { - throw new Error('Amount is outside the payee\'s sendable range.') + throw new Error("Amount is outside the payee's sendable range.") } const result = await requestInvoice(info.callback, amountMsat, options) invoice = result.pr @@ -106,6 +108,7 @@ export const payWithBearers = async ( const carve = await ensureExactAmount(bearers, amount, options) const k1 = requireNoteK1(carve.note.url) + if (carve.consumed.length === 0) assertFundOwner(options) let melt: MeltResult try { melt = await meltNote(carve.note.callback, k1, invoice, options) @@ -136,11 +139,7 @@ export const payWithBearers = async ( // either way (a service regenerating synthetic prs in proofs) and is // tolerated. const proofAmount = decodeBolt11AmountMsat(proof.pr) - if ( - !sameInvoice(proof.pr, invoice) && - proofAmount !== null && - proofAmount !== amount - ) { + if (!sameInvoice(proof.pr, invoice) && proofAmount !== null && proofAmount !== amount) { return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl} } return {outcome: 'settled', carve, invoice, amountMsat: amount, verifyUrl} @@ -158,12 +157,12 @@ export const payWithBearers = async ( ...carve, note: { ...carve.note, - url: withNewK1(carve.note.url, rotated.k1, amount, rotated.signature) - } + url: withNewK1(carve.note.url, rotated.k1, amount, rotated.signature), + }, }, invoice, amountMsat: amount, - verifyUrl + verifyUrl, } } catch (err) { if (err instanceof PendingNoteError) { @@ -183,7 +182,7 @@ export const payWithBearers = async ( url: withNewK1(carve.note.url, err.newSecrets[0], amount), callback: carve.note.callback, amount, - verified: false + verified: false, } if (carve.note.mintPubkey) rescuedNote.mintPubkey = carve.note.mintPubkey return { @@ -192,7 +191,7 @@ export const payWithBearers = async ( invoice, amountMsat: amount, verifyUrl, - rescuedNote + rescuedNote, } } return {outcome: 'unknown-still-pending', carve, invoice, amountMsat: amount, verifyUrl}