diff --git a/src/lnurlcash/nwc.invoice-a.cases.ts b/src/lnurlcash/nwc.invoice-a.cases.ts new file mode 100644 index 0000000..77b39cb --- /dev/null +++ b/src/lnurlcash/nwc.invoice-a.cases.ts @@ -0,0 +1,206 @@ +// The NWC wallet service end to end: connection strings and key +// derivation, the request/response cycle over an in-memory relay (the +// transport is injected - no network), every method against the +// conformance mock mint, the legacy NIP-04 path, budget enforcement, and +// the error paths. Fund-safety focus: budgets can't be exceeded, stale +// requests never execute, and a settled preimage only ever reveals an +// already-rotated (burned) note secret. + +import {afterEach, beforeEach, describe, expect, it} from 'vitest' +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 {createMockMint} from 'lnurlcash-conformance/mock-mint' + +import { + NWC_INFO_KIND, + NWC_REQUEST_KIND, + NWC_RESPONSE_KIND, + buildConnectionString, + connectionInfoOf, + createConnection, + deriveNwcWalletKey, + migrateLegacyNwcStorage, + parseConnectionString, + readNwcEnabled, + readNwcConnections, + startService, + writeNwcEnabled, + writeNwcConnections, +} from './nwc' +import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' +import type {NostrFilter} from './nwc/transport' +import type {NwcChangeset} from './nwc' +import type {Bearer} from './types' +import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' +import {requiredString, requiredValue, stubLocalStorage} from './test-utils' + +import { + CLIENT_PUBKEY, + CLIENT_SECRET, + FAST_POLL, + LINKING_KEY, + OTHER_LINKING_KEY, + OTHER_OWNER_ID, + OWNER_ID, + RELAYS, + STRANGER_SECRET, + clientRequest, + createFakeRelay, + deferred, + foreignConnectionFixture, + methodRequest, + nowSeconds, + storeForeignConnection, + waitFor, +} from './nwc.testProtocol' +import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService' +describe('service: make_invoice / lookup_invoice', () => { + it('issues an invoice, settles it in the background, and reports the preimage', async () => { + const m = await mint({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, + description: 'nwc test', + expiry: 3600, + }) + expect(made.error).toBeNull() + expect(made.result).toMatchObject({ + type: 'incoming', + state: 'pending', + amount: 21_000, + description: 'nwc test', + created_at: nowSeconds(), + expires_at: nowSeconds() + 3600, + }) + const invoice = requiredValue(made.result).invoice + if (typeof invoice !== 'string') { + throw new TypeError('make_invoice did not return an invoice') + } + const paymentHash = made.result?.payment_hash + if (typeof paymentHash !== 'string') { + throw new TypeError('make_invoice did not return a payment hash') + } + expect(invoice).toMatch(/^lnbc/) + expect(paymentHash).toMatch(/^[0-9a-f]{64}$/) + + // before settlement the lookup reports the pending invoice + const pending = await call(relay, walletServicePubkey, 'lookup_invoice', { + payment_hash: paymentHash, + }) + expect(pending.error).toBeNull() + expect(pending.result?.state).toBe('pending') + expect(pending.result?.preimage).toBeUndefined() + + // the "payer" pays the invoice; the background claim settles and + // mints the note + 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') + expect(settled.result?.settled_at).toBe(nowSeconds()) + const preimage = requiredString(settled.result?.preimage) + expect(preimage).toMatch(/^[0-9a-f]{64}$/) + + // the minted note was claimed AND rotated before settlement was + // recorded: the preimage the client just learned is a burned secret, + // and the wallet's fresh note is the only live one + expect(m.state.noteState(preimage)).toBe('burned') + const minted = requiredValue(state.bearers.find((b) => b.id.startsWith('added-'))) + expect(minted.amount).toBe(21_000) + expect(minted.verified).toBe(true) + expect(noteK1(minted.url)).not.toBe(preimage) + expect(m.state.noteState(requiredValue(noteK1(minted.url)))).toBe('outstanding') + await stop() + }) + + it('keeps a paid invoice pending while its bearer commit is deferred', async () => { + const m = await mint({testHooks: true}) + const commit = deferred() + let commitStarted = false + const {relay, walletServicePubkey, state, stop} = await startTestService({ + defaultMint: `mint@127.0.0.1:${m.port}`, + commitChangeset: () => { + commitStarted = true + return commit.promise + }, + }) + 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 settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`) + expect(settleResponse.ok).toBe(true) + await waitFor(() => commitStarted) + + const pending = await call(relay, walletServicePubkey, 'lookup_invoice', { + payment_hash: paymentHash, + }) + expect(pending.result?.state).toBe('pending') + expect(pending.result?.settled_at).toBeUndefined() + expect(pending.result?.preimage).toBeUndefined() + + commit.resolve() + await waitFor(() => state.changesets.length === 1) + const settled = await call(relay, walletServicePubkey, 'lookup_invoice', { + payment_hash: paymentHash, + }) + expect(settled.result?.state).toBe('settled') + expect(settled.result?.settled_at).toBe(nowSeconds()) + expect(settled.result?.preimage).toMatch(/^[0-9a-f]{64}$/) + await stop() + }) + + it('keeps repeated stops pending until an already-started invoice settlement commits', async () => { + const m = await mint({testHooks: true}) + const commit = deferred() + let commitStarted = false + const {relay, walletServicePubkey, state, stop} = await startTestService({ + defaultMint: `mint@127.0.0.1:${m.port}`, + commitChangeset: () => { + commitStarted = true + return commit.promise + }, + }) + 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 settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`) + expect(settleResponse.ok).toBe(true) + await waitFor(() => commitStarted) + + let stopped = false + const firstStop = stop().then(() => { + stopped = true + return state.changesets.length + }) + const repeatedStop = stop() + for (let turn = 0; turn < 10; turn += 1) await Promise.resolve() + + expect(stopped).toBe(false) + expect(state.changesets).toHaveLength(0) + + commit.resolve() + const [changesetsAtStop] = await Promise.all([firstStop, repeatedStop]) + expect(changesetsAtStop).toBe(1) + expect(state.changesets).toHaveLength(1) + }) +}) diff --git a/src/lnurlcash/nwc.invoice-b.cases.ts b/src/lnurlcash/nwc.invoice-b.cases.ts new file mode 100644 index 0000000..115e22a --- /dev/null +++ b/src/lnurlcash/nwc.invoice-b.cases.ts @@ -0,0 +1,241 @@ +// The NWC wallet service end to end: connection strings and key +// derivation, the request/response cycle over an in-memory relay (the +// transport is injected - no network), every method against the +// conformance mock mint, the legacy NIP-04 path, budget enforcement, and +// the error paths. Fund-safety focus: budgets can't be exceeded, stale +// requests never execute, and a settled preimage only ever reveals an +// already-rotated (burned) note secret. + +import {afterEach, beforeEach, describe, expect, it} from 'vitest' +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 {createMockMint} from 'lnurlcash-conformance/mock-mint' + +import { + NWC_INFO_KIND, + NWC_REQUEST_KIND, + NWC_RESPONSE_KIND, + buildConnectionString, + connectionInfoOf, + createConnection, + deriveNwcWalletKey, + migrateLegacyNwcStorage, + parseConnectionString, + readNwcEnabled, + readNwcConnections, + startService, + writeNwcEnabled, + writeNwcConnections, +} from './nwc' +import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc' +import type {NostrFilter} from './nwc/transport' +import type {NwcChangeset} from './nwc' +import type {Bearer} from './types' +import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys' +import {requiredValue, stubLocalStorage} from './test-utils' + +import { + CLIENT_PUBKEY, + CLIENT_SECRET, + FAST_POLL, + LINKING_KEY, + OTHER_LINKING_KEY, + OTHER_OWNER_ID, + OWNER_ID, + RELAYS, + STRANGER_SECRET, + clientRequest, + createFakeRelay, + deferred, + foreignConnectionFixture, + methodRequest, + nowSeconds, + storeForeignConnection, + waitFor, +} from './nwc.testProtocol' +import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService' +describe('service: make_invoice / lookup_invoice (continued)', () => { + it('drains a rejected invoice settlement and reports it before stop resolves', async () => { + const m = await mint({testHooks: true}) + const commit = deferred() + const commitError = new Error('invoice commit rejected during stop') + let commitStarted = false + const {relay, walletServicePubkey, state, stop} = await startTestService({ + defaultMint: `mint@127.0.0.1:${m.port}`, + commitChangeset: () => { + commitStarted = true + return commit.promise + }, + }) + 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 settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`) + expect(settleResponse.ok).toBe(true) + await waitFor(() => commitStarted) + + let stopped = false + const stopping = stop().then(() => { + stopped = true + return state.errors.length + }) + for (let turn = 0; turn < 10; turn += 1) await Promise.resolve() + expect(stopped).toBe(false) + + commit.reject(commitError) + expect(await stopping).toBe(1) + expect(state.errors).toEqual([commitError]) + expect(state.changesets).toHaveLength(0) + await stop() + }) + + it('does not block stop on an invoice whose claim is still polling', async () => { + // an unpaid invoice's claim poll can legally run for minutes (the + // client pays whenever it pays) - stop must interrupt the wait, not + // sit on it; a settlement that already REACHED the commit phase is + // still awaited (see the drain tests above) + const m = await mint({testHooks: true}) + const {relay, walletServicePubkey, state, stop} = await startTestService({ + defaultMint: `mint@127.0.0.1:${m.port}`, + claimPoll: {intervalMs: 50, intervalCapMs: 50, maxWaitMs: 60_000}, + }) + const made = await call(relay, walletServicePubkey, 'make_invoice', { + amount: 5_000, + }) + expect(made.error).toBeNull() + + // nobody pays the invoice; the claim keeps polling. stop must resolve + // promptly regardless (an un-interrupted stop would wait out the + // whole 60s claim budget) + await stop() + expect(state.changesets).toHaveLength(0) + expect(state.errors).toHaveLength(0) + }) + + it('does not start invoice settlement after stop begins during preparation', async () => { + const m = await mint({testHooks: true}) + const prepare = deferred() + let prepareStarted = false + const {relay, walletServicePubkey, state, stop} = await startTestService({ + defaultMint: `mint@127.0.0.1:${m.port}`, + kit: { + fetch: async (input, init) => { + prepareStarted = true + await prepare.promise + return fetch(input, init) + }, + }, + }) + const request = methodRequest(walletServicePubkey, 'make_invoice', { + amount: 21_000, + }) + relay.emit(request) + await waitFor(() => prepareStarted) + + let stopped = false + const stopping = stop().then(() => { + stopped = true + }) + for (let turn = 0; turn < 10; turn += 1) await Promise.resolve() + expect(stopped).toBe(false) + + prepare.resolve() + await stopping + + expect(state.changesets).toHaveLength(0) + expect(readResponse(relay.published, request.id, 'nip44_v2')?.error?.code).toBe('INTERNAL') + }) + + it('marks a paid invoice failed when its bearer commit rejects', async () => { + const m = await mint({testHooks: true}) + const commit = deferred() + const commitError = new Error('invoice bearer commit failed') + let commitStarted = false + const {relay, walletServicePubkey, state, stop} = await startTestService({ + defaultMint: `mint@127.0.0.1:${m.port}`, + commitChangeset: () => { + commitStarted = true + return commit.promise + }, + }) + 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 settleResponse = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`) + expect(settleResponse.ok).toBe(true) + await waitFor(() => commitStarted) + commit.reject(commitError) + await waitFor(() => state.errors.length === 1) + + const failed = await call(relay, walletServicePubkey, 'lookup_invoice', { + payment_hash: paymentHash, + }) + expect(failed.result?.state).toBe('failed') + expect(failed.result?.settled_at).toBeUndefined() + expect(failed.result?.preimage).toBeUndefined() + expect(state.errors).toEqual([commitError]) + await stop() + }) + + it('finds an invoice by its invoice string too', async () => { + const m = await mint({testHooks: true}) + const {relay, walletServicePubkey, stop} = await startTestService({ + defaultMint: `mint@127.0.0.1:${m.port}`, + claimPoll: {intervalMs: 1, intervalCapMs: 2, maxWaitMs: 10}, + }) + const made = await call(relay, walletServicePubkey, 'make_invoice', { + amount: 5_000, + }) + const invoice = made.result?.invoice + if (typeof invoice !== 'string') { + throw new TypeError('make_invoice did not return an invoice') + } + const found = await call(relay, walletServicePubkey, 'lookup_invoice', { + invoice: invoice.toUpperCase(), + }) + expect(found.error).toBeNull() + expect(found.result?.payment_hash).toBe(made.result?.payment_hash) + await stop() + }) + + it('answers an unknown invoice with NOT_FOUND', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const response = await call(relay, walletServicePubkey, 'lookup_invoice', { + payment_hash: 'ab'.repeat(32), + }) + expect(response.error?.code).toBe('NOT_FOUND') + await stop() + }) + + it('answers make_invoice without a default mint with INTERNAL', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({ + defaultMint: null, + }) + const response = await call(relay, walletServicePubkey, 'make_invoice', { + amount: 21_000, + }) + expect(response.error?.code).toBe('INTERNAL') + await stop() + }) + + it('answers a make_invoice with a bad amount with OTHER', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const response = await call(relay, walletServicePubkey, 'make_invoice', { + amount: -5, + }) + expect(response.error?.code).toBe('OTHER') + await stop() + }) +}) diff --git a/src/lnurlcash/nwc/invoices.ts b/src/lnurlcash/nwc/invoices.ts index 92cd9da..1fc1a5b 100644 --- a/src/lnurlcash/nwc/invoices.ts +++ b/src/lnurlcash/nwc/invoices.ts @@ -23,10 +23,10 @@ import type {PollOptions} from '../ops/shared' import type {PendingInvoice, RequestContext} from './context' -export const DEFAULT_CLAIM_POLL: Required = { +export const DEFAULT_CLAIM_POLL: Required> = { intervalMs: 2000, intervalCapMs: 10_000, - maxWaitMs: 15 * 60_000 + maxWaitMs: 15 * 60_000, } // LUD-21 verify URLs end in /verify/ (the protocol's verify @@ -41,9 +41,7 @@ export const resolvePaymentHash = (prepared: PreparedMint): string => { } // the NIP-47 transaction object make_invoice and lookup_invoice share -export const invoiceResult = ( - entry: PendingInvoice -): Record => { +export const invoiceResult = (entry: PendingInvoice): Record => { const result: Record = { type: 'incoming', state: entry.state, @@ -51,7 +49,7 @@ export const invoiceResult = ( payment_hash: entry.paymentHash, amount: entry.amountMsat, created_at: entry.createdAt, - metadata: {} + metadata: {}, } if (entry.description) result.description = entry.description if (entry.expiresAt) result.expires_at = entry.expiresAt @@ -64,47 +62,50 @@ export const invoiceResult = ( // 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 - -// so a preimage lookup can only ever reveal an already-burned secret. +// 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. // Throws on any failure; the caller marks the entry failed and reports // through deps.onError. -export const settleAndClaim = async ( - ctx: RequestContext, - entry: PendingInvoice -): Promise => { +export const settleAndClaim = async (ctx: RequestContext, entry: PendingInvoice): Promise => { if (!entry.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.', ) } + // the observation half is interruptible (the client may never pay, so + // the poll can legally outlive the service); once settlement is seen, + // everything below - claim, rotate, bearer commit - is fund-critical and + // deliberately ignores the stop signal: stop's drain awaits it const result = await pollVerifyUntilSettled( entry.prepared.verifyUrl, - ctx.deps.claimPoll ?? DEFAULT_CLAIM_POLL, - ctx.deps.kit ?? {} + {...(ctx.deps.claimPoll ?? DEFAULT_CLAIM_POLL), signal: ctx.stopSignal}, + ctx.deps.kit ?? {}, ) // 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, entry.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.') } // claimFromPreimage IS claimMintedNote's claim half (poll above is the // other half) - invoked in two steps here because NWC needs the // preimage, which claimMintedNote deliberately discards - const claimed = await claimFromPreimage( - entry.prepared, - preimage, - ctx.deps.kit ?? {} - ) + const claimed = await claimFromPreimage(entry.prepared, preimage, { + ...(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' if (claimed.rotated) { @@ -112,5 +113,4 @@ export const settleAndClaim = async ( // secret, safe to hand out as the settlement receipt entry.preimage = preimage } - ctx.deps.applyChangeset({add, markSpent: []}, ctx.connection(), 'make_invoice') }