From 7f69f781f55239de5502d8c17954d582a0ee45e6 Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 22 Aug 2026 16:55:12 +0200 Subject: [PATCH] fix: fence mint and receive operations --- src/lnurlcash/ops.mint-receive.cases.ts | 106 ++++++++++++++++++++++++ src/lnurlcash/ops/mint.ts | 66 +++++---------- src/lnurlcash/ops/receiveBearer.ts | 18 ++-- src/lnurlcash/receive.ts | 17 ++-- 4 files changed, 144 insertions(+), 63 deletions(-) create mode 100644 src/lnurlcash/ops.mint-receive.cases.ts diff --git a/src/lnurlcash/ops.mint-receive.cases.ts b/src/lnurlcash/ops.mint-receive.cases.ts new file mode 100644 index 0000000..9649448 --- /dev/null +++ b/src/lnurlcash/ops.mint-receive.cases.ts @@ -0,0 +1,106 @@ +import {describe, expect, it} from 'vitest' +import { + NoteSpentError, + PendingNoteError, + buildNoteUrl, + fetchNoteInfo, + meltNote, + noteK1, + rotateNote, +} from 'lnurlcash-kit' + +import {claimMintedNote, prepareMint, receiveBearer} from './ops' +import {requiredValue} from './test-utils' +import {makeBearer, mint, noteUrl, secret, settleLastInvoice} from './ops.testHarness' + +describe('mint -> claim -> rotate', () => { + it('mints a note from a paid invoice and rotates it immediately', async () => { + const instance = await mint({testHooks: true}) + const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) + expect(prepared.invoice).toMatch(/^lnbc/) + expect(prepared.verifyUrl).toBeTruthy() + expect(prepared.expectedNoteValueMsat).toBe(21_000) + const preimage = await settleLastInvoice(instance) + const claimed = await claimMintedNote(prepared, { + intervalMs: 10, + intervalCapMs: 50, + maxWaitMs: 5_000, + }) + expect(claimed.rotated).toBe(true) + expect(claimed.note.amount).toBe(21_000) + expect(claimed.note.verified).toBe(true) + expect(instance.state.noteState(preimage)).toBe('burned') + const k1 = requiredValue(noteK1(claimed.note.url)) + expect(k1).not.toBe(preimage) + expect(instance.state.noteState(k1)).toBe('outstanding') + }) + + it('grosses the invoice up for an advertised mint fee', async () => { + const instance = await mint({testHooks: true, baseFeeMsat: 1_000, feePpm: 2_000}) + const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 100_000) + expect(prepared.grossMsat).toBeGreaterThan(100_000) + const preimage = await settleLastInvoice(instance) + const info = await fetchNoteInfo( + buildNoteUrl(prepared.withdrawLink, preimage, prepared.expectedNoteValueMsat), + ) + expect(info.maxWithdrawable).toBeGreaterThanOrEqual(99_000) + expect(info.maxWithdrawable).toBeLessThanOrEqual(prepared.grossMsat) + await expect( + claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 500}), + ).rejects.toThrow(/different invoice/) + }) + + it('times out cleanly when the invoice is never paid', async () => { + const instance = await mint({testHooks: true}) + const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) + await expect( + claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 100}), + ).rejects.toThrow(/not confirmed/i) + }) +}) + +describe('receiveBearer', () => { + it("verifies an incoming note and rotates it, burning the sender's copy", async () => { + const instance = await mint() + const senderK1 = secret('20') + instance.state.creditNote(senderK1, 21_000) + const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), []) + expect(received.rotated).toBe(true) + expect(received.note.amount).toBe(21_000) + expect(received.note.verified).toBe(true) + const newK1 = requiredValue(noteK1(received.note.url)) + expect(newK1).not.toBe(senderK1) + expect(instance.state.noteState(senderK1)).toBe('burned') + expect(instance.state.noteState(newK1)).toBe('outstanding') + }) + + it('refuses a note the wallet already holds', async () => { + const instance = await mint() + const senderK1 = secret('21') + const existing = await makeBearer(instance, senderK1, 21_000) + await expect(receiveBearer(noteUrl(instance, senderK1, 21_000), [existing])).rejects.toThrow( + /already/, + ) + }) + + it('surfaces a spent note as definitively spent', async () => { + const instance = await mint() + const k1 = secret('22') + const bearer = await makeBearer(instance, k1, 21_000) + const info = await fetchNoteInfo(bearer.url) + await rotateNote(info.callback, k1) + await expect(receiveBearer(noteUrl(instance, k1, 21_000), [])).rejects.toBeInstanceOf( + NoteSpentError, + ) + }) + + it('surfaces a note locked mid-melt as pending, not as unverified', async () => { + const instance = await mint({meltNeverSettles: true}) + const k1 = secret('23') + const bearer = await makeBearer(instance, k1, 21_000) + await meltNote(bearer.callback, k1, 'lnbc21n1pjqrstuvwxyz') + await expect(receiveBearer(noteUrl(instance, k1, 21_000), [])).rejects.toBeInstanceOf( + PendingNoteError, + ) + }) +}) diff --git a/src/lnurlcash/ops/mint.ts b/src/lnurlcash/ops/mint.ts index 27a802d..82f558c 100644 --- a/src/lnurlcash/ops/mint.ts +++ b/src/lnurlcash/ops/mint.ts @@ -19,13 +19,14 @@ import { rotateNote, sameInvoice, serverOf, - withNewK1 + withNewK1, } from 'lnurlcash-kit' -import type {LnurlcashOptions, MintAddressInfo} from 'lnurlcash-kit' +import type {MintAddressInfo} from 'lnurlcash-kit' import type {NewBearer} from '../types' import {ceilMsatToSat} from '../units' import type {PollOptions} from './shared' -import {pollVerifyUntilSettled} from './shared' +import type {FundOperationOptions} from './shared' +import {assertFundOwner, pollVerifyUntilSettled} from './shared' export type PreparedMint = { invoice: string @@ -52,7 +53,7 @@ export type PreparedMint = { export const prepareMint = async ( mintInput: string, amountMsat: number, - options: LnurlcashOptions = {} + options: FundOperationOptions = {}, ): Promise => { if (!Number.isInteger(amountMsat) || amountMsat <= 0) { throw new Error('Amount must be a positive whole number of msat.') @@ -69,21 +70,20 @@ export const prepareMint = async ( try { nodeInfo = await fetchMintAddress(addressUrl, options) payUrl = nodeInfo.payLink - } catch { + } catch (error) { // no mint-address support here - proceed with just the guess + if (!(error instanceof Error)) throw error } } const info = await fetchPayRequest(payUrl, options) if (!info.withdrawLink) { - throw new Error( - 'This payRequest does not advertise lnurlcash minting (no withdrawLink).' - ) + throw new Error('This payRequest does not advertise lnurlcash minting (no withdrawLink).') } const grossMsat = ceilMsatToSat( - info.mintFee ? grossUpForMintFee(amountMsat, info.mintFee) : amountMsat + info.mintFee ? grossUpForMintFee(amountMsat, info.mintFee) : amountMsat, ) if (grossMsat < info.minSendable || grossMsat > info.maxSendable) { - throw new Error('Amount is outside this mint\'s sendable range.') + throw new Error("Amount is outside this mint's sendable range.") } const invoice = await requestInvoice(info.callback, grossMsat, options) const prepared: PreparedMint = { @@ -95,7 +95,7 @@ export const prepareMint = async ( withdrawLink: info.withdrawLink, server: serverOf(payUrl), username: lightningAddressUsername(payUrl), - nodeInfo + nodeInfo, } if (info.mintPubkey) prepared.mintPubkey = info.mintPubkey return prepared @@ -123,11 +123,11 @@ export type ClaimedNote = { export const claimMintedNote = async ( prepared: PreparedMint, poll: PollOptions = {}, - options: LnurlcashOptions = {} + options: FundOperationOptions = {}, ): Promise => { if (!prepared.verifyUrl) { throw new Error( - 'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.' + 'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.', ) } const verifyUrl = prepared.verifyUrl @@ -135,15 +135,11 @@ export const claimMintedNote = async ( // a settled report only means this wallet's invoice was paid if it's for // the invoice this wallet actually requested if (!sameInvoice(result.pr, prepared.invoice)) { - throw new Error( - "The service's verify response is for a different invoice than requested." - ) + throw new Error("The service's verify response is for a different invoice than requested.") } const preimage = result.preimage if (!preimage || !isPreimage(preimage)) { - throw new Error( - 'The payment settled but the service did not reveal the preimage.' - ) + throw new Error('The payment settled but the service did not reveal the preimage.') } return claimFromPreimage(prepared, preimage, options) } @@ -168,15 +164,12 @@ export type ClaimTarget = { export const claimFromPreimage = async ( claim: ClaimTarget, preimage: string, - options: LnurlcashOptions = {} + options: FundOperationOptions = {}, ): Promise => { // declare the invoiced amount (a claim - not yet confirmed) so the note // is self-describing even before the verifying GET below - const declaredUrl = buildNoteUrl( - claim.withdrawLink, - preimage, - claim.expectedNoteValueMsat - ) + const declaredUrl = buildNoteUrl(claim.withdrawLink, preimage, claim.expectedNoteValueMsat) + assertFundOwner(options) // the service's maxWithdrawable is authoritative - SERVICE's own fee // math might not match this wallet's estimate, and the note is worth // exactly maxWithdrawable regardless @@ -186,7 +179,7 @@ export const claimFromPreimage = async ( url: withNewK1(declaredUrl, noteInfo.k1, noteInfo.maxWithdrawable), callback: noteInfo.callback, amount: noteInfo.maxWithdrawable, - verified: true + verified: true, } if (mintPubkey) base.mintPubkey = mintPubkey @@ -196,12 +189,7 @@ export const claimFromPreimage = async ( let rotationError: string | undefined try { const rotatedNote = await rotateNote(noteInfo.callback, noteInfo.k1, options) - url = withNewK1( - declaredUrl, - rotatedNote.k1, - noteInfo.maxWithdrawable, - rotatedNote.signature - ) + url = withNewK1(declaredUrl, rotatedNote.k1, noteInfo.maxWithdrawable, rotatedNote.signature) } catch (err) { rotated = false if (err instanceof AmbiguousMutationError) { @@ -210,24 +198,16 @@ export const claimFromPreimage = async ( const outcome = await probeBurnedNote(declaredUrl, options) if (outcome === 'gone') { // the burn landed - adopt the fresh secret as the note - url = withNewK1( - declaredUrl, - err.newSecrets[0], - noteInfo.maxWithdrawable - ) + url = withNewK1(declaredUrl, err.newSecrets[0], noteInfo.maxWithdrawable) rotated = true } else if (outcome === 'unknown') { // can't tell: the preimage note is returned either way - the // possible rotated copy goes alongside it, both refreshable possibleCopy = { - url: withNewK1( - declaredUrl, - err.newSecrets[0], - noteInfo.maxWithdrawable - ), + url: withNewK1(declaredUrl, err.newSecrets[0], noteInfo.maxWithdrawable), callback: noteInfo.callback, amount: noteInfo.maxWithdrawable, - verified: false + verified: false, } if (mintPubkey) possibleCopy.mintPubkey = mintPubkey rotationError = `${err.message} The rotation may still have gone through - the possible rotated copy is tracked unverified alongside this one.` diff --git a/src/lnurlcash/ops/receiveBearer.ts b/src/lnurlcash/ops/receiveBearer.ts index c151c61..13cbe87 100644 --- a/src/lnurlcash/ops/receiveBearer.ts +++ b/src/lnurlcash/ops/receiveBearer.ts @@ -10,12 +10,13 @@ import { NoteUnknownError, PendingNoteError, probeBurnedNote, - withNewK1 + withNewK1, } from 'lnurlcash-kit' -import type {LnurlcashOptions} from 'lnurlcash-kit' import type {Bearer, NewBearer} from '../types' import {receiveNote, secureReceivedNote} from '../receive' import type {ClaimedNote} from './mint' +import type {FundOperationOptions} from './shared' +import {assertFundOwner} from './shared' // NoteSpentError / NoteUnknownError / PendingNoteError from the service are // definitive and propagate; an unreachable service still yields the note, @@ -23,13 +24,14 @@ import type {ClaimedNote} from './mint' export const receiveBearer = async ( input: string, existing: Bearer[], - options: LnurlcashOptions = {} + options: FundOperationOptions = {}, ): Promise => { const note = await receiveNote(input, existing) if (!note.verified || !note.callback) { return {note, rotated: false} } try { + assertFundOwner(options) const rotatedUrl = await secureReceivedNote(note) return {note: {...note, url: rotatedUrl}, rotated: true} } catch (err) { @@ -49,9 +51,9 @@ export const receiveBearer = async ( return { note: { ...note, - url: withNewK1(note.url, err.newSecrets[0], note.amount) + url: withNewK1(note.url, err.newSecrets[0], note.amount), }, - rotated: true + rotated: true, } } if (outcome === 'unknown') { @@ -59,14 +61,14 @@ export const receiveBearer = async ( url: withNewK1(note.url, err.newSecrets[0], note.amount), callback: note.callback, amount: note.amount, - verified: false + verified: false, } if (note.mintPubkey) possibleCopy.mintPubkey = note.mintPubkey return { note, rotated: false, possibleCopy, - rotationError: `${err.message} The rotation may still have gone through - the possible rotated copy is tracked unverified alongside this one.` + rotationError: `${err.message} The rotation may still have gone through - the possible rotated copy is tracked unverified alongside this one.`, } } } @@ -75,7 +77,7 @@ export const receiveBearer = async ( return { note, rotated: false, - rotationError: err instanceof Error ? err.message : String(err) + rotationError: err instanceof Error ? err.message : String(err), } } } diff --git a/src/lnurlcash/receive.ts b/src/lnurlcash/receive.ts index 9efdb95..897cd56 100644 --- a/src/lnurlcash/receive.ts +++ b/src/lnurlcash/receive.ts @@ -8,7 +8,7 @@ import { withNewK1, NoteSpentError, NoteUnknownError, - PendingNoteError + PendingNoteError, } from 'lnurlcash-kit' import type {Bearer, NewBearer} from './types' @@ -17,20 +17,13 @@ import type {Bearer, NewBearer} from './types' // always puts k1 on the wire, so receive.ts's caller should rotate right // after, see secureReceivedNote). Returns the note even when the info fetch // fails - a bearer is better stored unverified than dropped. -export const receiveNote = async ( - input: string, - existing: Bearer[] -): Promise => { +export const receiveNote = async (input: string, existing: Bearer[]): Promise => { const url = resolveNoteInput(input) if (!url) { throw new Error('Not an LNURLcash bearer note (needs a k1).') } const k1 = noteK1(url) - if ( - existing.some( - b => noteK1(b.url) === k1 && serverOf(b.url) === serverOf(url) - ) - ) { + if (existing.some((b) => noteK1(b.url) === k1 && serverOf(b.url) === serverOf(url))) { throw new Error('This note is already in your wallet.') } try { @@ -40,7 +33,7 @@ export const receiveNote = async ( callback: info.callback, amount: info.maxWithdrawable, verified: true, - mintPubkey: info.mintPubkey + mintPubkey: info.mintPubkey, } } catch (err) { // the service positively told us this k1 is dead, unknown, or locked @@ -62,7 +55,7 @@ export const receiveNote = async ( url, callback: '', amount: noteDeclaredAmount(url) ?? 0, - verified: false + verified: false, } } }