diff --git a/src/lnurlcash/nwc.pay-a.cases.ts b/src/lnurlcash/nwc.pay-a.cases.ts new file mode 100644 index 0000000..9d60619 --- /dev/null +++ b/src/lnurlcash/nwc.pay-a.cases.ts @@ -0,0 +1,235 @@ +// 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: pay_invoice', () => { + it('applies the settled changeset before publishing success', async () => { + const m = await mint() + const {relay, walletServicePubkey, state, stop} = await startTestService({}) + state.bearers = [await makeBearer(m, 'dc'.repeat(32), 21_000)] + + const response = await call(relay, walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc210n1pjqrstuvwxyz', + }) + + expect(response.error).toBeNull() + expect(state.changesets).toHaveLength(1) + expect(state.bearers[0]?.spent).toBe(true) + await stop() + }) + + it('pays a bolt11 by melting, returning the melt preimage and recording the spend', async () => { + const m = await mint() + const {relay, walletServicePubkey, state, stop} = await startTestService({ + budgetMsat: 50_000, + }) + const foreign = foreignConnectionFixture( + {maxMsat: 99_000, periodMs: 86_400_000}, + nowSeconds() * 1000, + ) + storeForeignConnection(foreign.record) + state.bearers = [await makeBearer(m, 'dd'.repeat(32), 21_000)] + + const response = await call(relay, walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc210n1pjqrstuvwxyz', + }) + expect(response.error).toBeNull() + expect(requiredString(response.result?.preimage)).toHaveLength(64) + + // the note is gone (melted) and locked spent via the changeset + expect(m.state.noteState('dd'.repeat(32))).toBe('burned') + expect(requiredValue(state.bearers[0]).spent).toBe(true) + + // the spend was recorded against the budget, persisted + expect(readNwcConnections(OWNER_ID)[0]?.spent.msat).toBe(21_000) + expect(readNwcConnections(OTHER_OWNER_ID)[0]?.spent.msat).toBe(0) + await stop() + }) + + it('waits for a deferred bearer commit before publishing payment success', async () => { + const m = await mint() + const commit = deferred() + let commitStarted = false + const {relay, walletServicePubkey, state, stop} = await startTestService({ + commitChangeset: () => { + commitStarted = true + return commit.promise + }, + }) + state.bearers = [await makeBearer(m, 'db'.repeat(32), 21_000)] + const request = methodRequest(walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc210n1pjqrstuvwxyz', + }) + + relay.emit(request) + await waitFor(() => commitStarted) + + // a non-awaiting engine publishes within milliseconds of the commit + // call (one local verify re-read + encrypt); this window is far wider + // than that, so an early response here can only mean a missing barrier + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull() + commit.resolve() + await waitFor(() => readResponse(relay.published, request.id, 'nip44_v2') !== null) + expect(readResponse(relay.published, request.id, 'nip44_v2')?.error).toBeNull() + await stop() + }) + + it('reports a rejected bearer commit without publishing payment success', async () => { + const m = await mint() + const commit = deferred() + const commitError = new Error('bearer commit failed') + let commitStarted = false + const {relay, walletServicePubkey, state, stop} = await startTestService({ + commitChangeset: () => { + commitStarted = true + return commit.promise + }, + }) + state.bearers = [await makeBearer(m, 'da'.repeat(32), 21_000)] + const request = methodRequest(walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc210n1pjqrstuvwxyz', + }) + + relay.emit(request) + await waitFor(() => commitStarted) + commit.reject(commitError) + await waitFor( + () => + state.errors.length > 0 || readResponse(relay.published, request.id, 'nip44_v2') !== null, + ) + + expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull() + expect(state.errors).toEqual([commitError]) + // the conservative budget debit is persisted separately from bearer + // storage and deliberately NOT rolled back: the budget was debited, + // the bearer was never locked spent + expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(21_000) + expect(state.bearers[0]?.spent).toBeUndefined() + await stop() + }) + + it('drains an in-flight pay across repeated stops once its deferred commit resolves', async () => { + const m = await mint() + const commit = deferred() + let commitStarted = false + const {relay, walletServicePubkey, state, stop} = await startTestService({ + commitChangeset: () => { + commitStarted = true + return commit.promise + }, + }) + state.bearers = [await makeBearer(m, 'd9'.repeat(32), 21_000)] + const request = methodRequest(walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc210n1pjqrstuvwxyz', + }) + + relay.emit(request) + await waitFor(() => commitStarted) + // stop closes subscriptions; in-flight handlers still finish (their + // changesets hold money) - a repeated stop interrupts nothing twice + let stopped = false + const firstStop = stop().then(() => { + stopped = true + }) + const repeatedStop = stop() + await Promise.resolve() + expect(stopped).toBe(false) + // subscriptions close IMMEDIATELY, before the drain completes + expect(relay.subscriptionCount()).toBe(0) + + const afterStop = methodRequest(walletServicePubkey, 'get_balance', {}) + relay.emitAfterClose(afterStop) + await new Promise((resolve) => setTimeout(resolve, 25)) + expect(readResponse(relay.published, afterStop.id, 'nip44_v2')).toBeNull() + + commit.resolve() + await Promise.all([firstStop, repeatedStop]) + expect(readResponse(relay.published, request.id, 'nip44_v2')?.error).toBeNull() + expect(state.bearers[0]?.spent).toBe(true) + }) + + it('rejects a stale saved owner before touching the mint', async () => { + const m = await mint() + const {relay, walletServicePubkey, state, stop} = await startTestService({}) + state.bearers = [await makeBearer(m, 'd8'.repeat(32), 21_000)] + localStorage.setItem( + 'sattle_linking_key', + JSON.stringify({ + enc: false, + value: bytesToHex(OTHER_LINKING_KEY), + ownerId: OTHER_OWNER_ID, + version: 1, + }), + ) + const request = methodRequest(walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc210n1pjqrstuvwxyz', + }) + + relay.emit(request) + await waitFor( + () => + state.errors.length > 0 || readResponse(relay.published, request.id, 'nip44_v2') !== null, + ) + + expect(m.state.noteState('d8'.repeat(32))).toBe('outstanding') + expect(state.changesets).toEqual([]) + expect(readResponse(relay.published, request.id, 'nip44_v2')?.error?.code).toBe('INTERNAL') + await stop() + }) +}) diff --git a/src/lnurlcash/nwc/budget.ts b/src/lnurlcash/nwc/budget.ts index 74320ee..628c970 100644 --- a/src/lnurlcash/nwc/budget.ts +++ b/src/lnurlcash/nwc/budget.ts @@ -8,10 +8,7 @@ import type {NwcConnectionRecord} from '../storage/nwcConnections' import {persistNwcConnection} from '../storage/nwcConnections' -export const budgetRemainingMsat = ( - record: NwcConnectionRecord, - nowMs: number -): number => { +export const budgetRemainingMsat = (record: NwcConnectionRecord, nowMs: number): number => { const {maxMsat, periodMs} = record.budget if (nowMs - record.spent.periodStart >= periodMs) return maxMsat return Math.max(0, maxMsat - record.spent.msat) @@ -20,18 +17,19 @@ export const budgetRemainingMsat = ( // rolls the period when it expired, then adds the spend; persists (the // caller's queue serialized this read-modify-write) export const recordSpend = ( + ownerId: string, record: NwcConnectionRecord, amountMsat: number, - nowMs: number + nowMs: number, ): NwcConnectionRecord => { const expired = nowMs - record.spent.periodStart >= record.budget.periodMs - return persistNwcConnection({ + return persistNwcConnection(ownerId, { ...record, spent: expired ? {periodStart: nowMs, msat: amountMsat} : { periodStart: record.spent.periodStart, - msat: record.spent.msat + amountMsat - } + msat: record.spent.msat + amountMsat, + }, }) }