diff --git a/src/lnurlcash/nwc.invoice-a.cases.ts b/src/lnurlcash/nwc.invoice-a.cases.ts index 77b39cb..109e548 100644 --- a/src/lnurlcash/nwc.invoice-a.cases.ts +++ b/src/lnurlcash/nwc.invoice-a.cases.ts @@ -11,7 +11,7 @@ import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' import {v2 as nip44v2} from 'nostr-tools/nip44' -import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' +import {buildNoteUrl, fetchNoteInfo, hashK1, noteK1} from 'lnurlcash-kit' import {createMockMint} from 'lnurlcash-conformance/mock-mint' import { @@ -124,6 +124,129 @@ describe('service: make_invoice / lookup_invoice', () => { await stop() }) + it('settles against a NAMED mint: note at the wallet secret, preimage a plain receipt', async () => { + const m = await mint({commentAllowed: 64, testHooks: true}) + const {relay, walletServicePubkey, state, stop} = await startTestService({ + defaultMint: `mint@127.0.0.1:${m.port}`, + }) + + const made = await call(relay, walletServicePubkey, 'make_invoice', { + amount: 21_000, + }) + expect(made.error).toBeNull() + const paymentHash = made.result?.payment_hash + if (typeof paymentHash !== 'string') { + throw new TypeError('make_invoice did not return a payment hash') + } + + const settleRes = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`) + expect(settleRes.ok).toBe(true) + await waitFor(() => state.changesets.some((c) => c.add.length > 0)) + + const settled = await call(relay, walletServicePubkey, 'lookup_invoice', { + payment_hash: paymentHash, + }) + expect(settled.error).toBeNull() + expect(settled.result?.state).toBe('settled') + // a named mint's verify preimage is an ordinary payment receipt - + // it keys nothing at the mint - so it is always safe to hand out + const preimage = requiredString(settled.result?.preimage) + expect(preimage).toMatch(/^[0-9a-f]{64}$/) + expect(m.state.noteState(preimage)).toBeNull() + + // the note was claimed at the wallet's own secret - unrotated, + // because nothing exposed it + const minted = requiredValue(state.bearers.find((b) => b.id.startsWith('added-'))) + expect(minted.amount).toBe(21_000) + expect(minted.verified).toBe(true) + const mintedK1 = requiredValue(noteK1(minted.url)) + expect(m.state.noteState(mintedK1)).toBe('outstanding') + // the quote was bound to that secret's hash + expect([...m.state.invoices.values()].at(-1)?.boundTo).toBe(hashK1(mintedK1)) + await stop() + }) + + it('rescues a NAMED mint that credited the preimage, and burns it before handing it out', async () => { + // the mintToHashIgnoresH adversary: advertises output naming, binds + // nothing, credits the payment hash - and (non-compliantly) still + // serves verify, so the preimage path can recover the note + const m = await mint({ + commentAllowed: 64, + mintToHashIgnoresH: true, + verifyOnUnnamedMint: true, + testHooks: true, + }) + const {relay, walletServicePubkey, state, stop} = await startTestService({ + defaultMint: `mint@127.0.0.1:${m.port}`, + claimPoll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 400}, + }) + + const made = await call(relay, walletServicePubkey, 'make_invoice', {amount: 21_000}) + const paymentHash = made.result?.payment_hash + if (typeof paymentHash !== 'string') { + throw new TypeError('make_invoice did not return a payment hash') + } + const settleRes = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`) + expect(settleRes.ok).toBe(true) + await waitFor(() => state.changesets.some((c) => c.add.length > 0)) + + const settled = await call(relay, walletServicePubkey, 'lookup_invoice', { + payment_hash: paymentHash, + }) + expect(settled.result?.state).toBe('settled') + // the rescue claimed through the preimage path and rotated off it, so + // the receipt handed to the client is a burned secret + const preimage = requiredString(settled.result?.preimage) + expect(m.state.noteState(preimage)).toBe('burned') + const minted = requiredValue(state.bearers.find((b) => b.id.startsWith('added-'))) + const mintedK1 = requiredValue(noteK1(minted.url)) + expect(mintedK1).not.toBe(preimage) + expect(m.state.noteState(mintedK1)).toBe('outstanding') + await stop() + }) + + it('withholds the preimage when the rescue claim could not rotate', async () => { + // same lying mint, but the rotate's request never lands (the mint + // dies between the claim and the rotate): the preimage may still be + // the note's secret, so it stays a secret even though the invoice + // reads settled + const m = await mint({ + commentAllowed: 64, + mintToHashIgnoresH: true, + verifyOnUnnamedMint: true, + testHooks: true, + }) + const rotateFailingFetch: typeof fetch = (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (url.includes('/w/cb')) return Promise.reject(new Error('connection reset')) + return fetch(input, init) + } + const {relay, walletServicePubkey, state, stop} = await startTestService({ + defaultMint: `mint@127.0.0.1:${m.port}`, + claimPoll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 400}, + kit: {fetch: rotateFailingFetch}, + }) + + const made = await call(relay, walletServicePubkey, 'make_invoice', {amount: 21_000}) + const paymentHash = made.result?.payment_hash + if (typeof paymentHash !== 'string') { + throw new TypeError('make_invoice did not return a payment hash') + } + const settleRes = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`) + expect(settleRes.ok).toBe(true) + await waitFor(() => state.changesets.some((c) => c.add.length > 0)) + + const settled = await call(relay, walletServicePubkey, 'lookup_invoice', { + payment_hash: paymentHash, + }) + expect(settled.result?.state).toBe('settled') + // settled, but NO receipt: an unrotated preimage may still be the note + expect(settled.result?.preimage).toBeUndefined() + // the note is tracked either way (it IS money) + expect(state.bearers.some((b) => b.id.startsWith('added-'))).toBe(true) + await stop() + }) + it('keeps a paid invoice pending while its bearer commit is deferred', async () => { const m = await mint({testHooks: true}) const commit = deferred() diff --git a/src/lnurlcash/nwc/invoices.ts b/src/lnurlcash/nwc/invoices.ts index 1fc1a5b..e1a8caa 100644 --- a/src/lnurlcash/nwc/invoices.ts +++ b/src/lnurlcash/nwc/invoices.ts @@ -1,15 +1,18 @@ // The invoice registry the two-phase make_invoice / lookup_invoice pair // shares: make_invoice answers with the invoice immediately while this // module's settleAndClaim watches settlement in the background, claims -// the minted note (rotating it immediately), and only THEN records the -// settlement the lookup reports. +// the minted note, and only THEN records the settlement the lookup +// reports. // -// THE PREIMAGE, in lnurlcash terms: the payment preimage IS the minted -// note's initial secret. NIP-47 clients expect it as the settlement -// receipt, so it is handed out - but only after claimFromPreimage has -// rotated the fresh note, when that secret is burned and worthless. If -// the rotation failed (claimed.rotated === false) the preimage is -// withheld: fund safety over spec comfort. +// THE PREIMAGE, in lnurlcash terms: on an UNNAMED mint the payment +// preimage IS the minted note's initial secret. NIP-47 clients expect it +// as the settlement receipt, so it is handed out - but only after +// claimFromPreimage has rotated the fresh note, when that secret is +// burned and worthless. If the rotation failed (claimed.rotated === +// false) the preimage is withheld: fund safety over spec comfort. On a +// NAMED mint the note lives at a wallet-chosen secret from the start and +// the preimage opens nothing at all, so it is an ordinary payment receipt +// and always safe to hand out. import {isPreimage, sameInvoice} from 'lnurlcash-kit' import {sha256} from '@noble/hashes/sha2.js' @@ -17,7 +20,7 @@ import {bytesToHex, utf8ToBytes} from '@noble/hashes/utils.js' import type {NewBearer} from '../types' import type {PreparedMint} from '../ops' -import {claimFromPreimage} from '../ops/mint' +import {claimFromPreimage, claimFromSecret} from '../ops/mint' import {pollVerifyUntilSettled} from '../ops/shared' import type {PollOptions} from '../ops/shared' @@ -61,13 +64,14 @@ export const invoiceResult = (entry: PendingInvoice): Record => } // the background half of make_invoice: watch the invoice, and once it -// settles claim the note (rotating it immediately) and hand the fresh -// bearer to the caller. Settlement is recorded LAST - after the rotate and -// bearer commit - so lookup can only reveal durably tracked funds and an -// already-burned secret. +// settles claim the note (rotating it immediately on an unnamed mint) and +// hand the fresh bearer to the caller. Settlement is recorded LAST - after +// the claim and bearer commit - so lookup can only reveal durably tracked +// funds and, on an unnamed mint, an already-burned secret. // Throws on any failure; the caller marks the entry failed and reports // through deps.onError. export const settleAndClaim = async (ctx: RequestContext, entry: PendingInvoice): Promise => { + if (entry.prepared.mode === 'named') return settleAndClaimNamed(ctx, entry) if (!entry.prepared.verifyUrl) { throw new Error( 'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.', @@ -114,3 +118,61 @@ export const settleAndClaim = async (ctx: RequestContext, entry: PendingInvoice) entry.preimage = preimage } } + +// the named-mint half of settleAndClaim: the note lands at the wallet's +// own secret, so its APPEARANCE there is the settlement observation and no +// rotate follows. When the mint also serves the quote's verify, that +// answer's preimage is kept as the NIP-47 receipt (an ordinary payment +// proof on a named mint); a named mint without verify yields a settled +// lookup with no preimage - correct, just receipt-less. +const settleAndClaimNamed = async ( + ctx: RequestContext, + entry: PendingInvoice, +): Promise => { + const noteSecret = entry.prepared.noteSecret + if (!noteSecret) { + throw new Error('This mint was prepared in named mode without a note secret.') + } + const prepared = entry.prepared + const claimPoll = ctx.deps.claimPoll ?? DEFAULT_CLAIM_POLL + let preimage: string | null = null + if (prepared.verifyUrl) { + // the interruptible observation half: watch settlement on verify, + // then claim by secret below (fund-critical, uninterruptible) + const result = await pollVerifyUntilSettled( + prepared.verifyUrl, + {...claimPoll, signal: ctx.stopSignal}, + ctx.deps.kit ?? {}, + ) + if (!sameInvoice(result.pr, prepared.invoice)) { + throw new Error("The service's verify response is for a different invoice than requested.") + } + if (result.preimage && isPreimage(result.preimage)) preimage = result.preimage + } + // without a verify URL the claim poll IS the observation half, so it + // stays interruptible; after a verify-observed settlement the note is + // already there and this returns on the first check + const claimed = await claimFromSecret( + {...prepared, noteSecret}, + prepared.verifyUrl ? claimPoll : {...claimPoll, signal: ctx.stopSignal}, + { + ...(ctx.deps.kit ?? {}), + assertOwner: ctx.assertOwner, + }, + ) + const add: NewBearer[] = [claimed.note] + if (claimed.possibleCopy) add.push(claimed.possibleCopy) + await ctx.deps.applyChangeset( + {add, markSpent: []}, + ctx.connection(), + 'make_invoice', + ctx.assertOwner, + ) + entry.settledAt = ctx.nowSeconds() + entry.state = 'settled' + // the rotate guard still matters here: the rescue path (a mint that + // took the hash but credited the PREIMAGE) claims through + // claimFromPreimage, and if its rotate failed the preimage is still a + // LIVE note secret - withheld exactly like the unnamed path + if (preimage && claimed.rotated) entry.preimage = preimage +}