diff --git a/src/lnurlcash/nwc.fencing.cases.ts b/src/lnurlcash/nwc.fencing.cases.ts new file mode 100644 index 0000000..1f28488 --- /dev/null +++ b/src/lnurlcash/nwc.fencing.cases.ts @@ -0,0 +1,66 @@ +// Stale-owner fencing end to end: a service accepted work while its owner +// was still installed, and a second tab replaced the saved key AFTER the +// fence's last safe point - mid-melt, past the boundary where aborting is +// no longer possible. What must not happen is any stale-owner WRITE: +// no bearer changeset, no budget debit, no success response. + +import {describe, expect, it} from 'vitest' +import {bytesToHex} from '@noble/hashes/utils.js' + +import {readNwcConnections} from './nwc' +import { + methodRequest, + OTHER_LINKING_KEY, + OTHER_OWNER_ID, + OWNER_ID, + waitFor, +} from './nwc.testProtocol' +import {makeBearer, mint, readResponse, startTestService} from './nwc.testService' + +describe('service: post-boundary stale-owner fencing', () => { + it('makes no stale-owner writes when the saved key is replaced mid-melt', async () => { + // Given a running service whose next mint call coincides with a second + // tab installing its own wallet (the first fetch is the melt itself: + // the exact-match carve performs no mint call of its own) + const m = await mint() + let swapped = false + const swappingFetch: typeof fetch = (input, init) => { + if (!swapped) { + swapped = true + localStorage.setItem( + 'sattle_linking_key', + JSON.stringify({ + enc: false, + value: bytesToHex(OTHER_LINKING_KEY), + ownerId: OTHER_OWNER_ID, + version: 1, + }), + ) + } + return fetch(input, init) + } + const {relay, walletServicePubkey, state, stop} = await startTestService({ + kit: {fetch: swappingFetch}, + }) + state.bearers = [await makeBearer(m, 'd7'.repeat(32), 21_000)] + const request = methodRequest(walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc210n1pjqrstuvwxyz', + }) + + // When the pay flows past the irreversible boundary and reaches the + // first owner-bound persistence (the conservative budget debit) + relay.emit(request) + await waitFor(() => state.errors.length > 0) + + // Then the melt genuinely happened (we are past the boundary) ... + expect(swapped).toBe(true) + expect(m.state.noteState('d7'.repeat(32))).toBe('burned') + // ... but nothing under the stale owner moved: no budget debit, no + // bearer changeset, no success response (the failure surfaced through + // onError instead) + expect(readNwcConnections(OWNER_ID)[0]?.spent.msat).toBe(0) + expect(state.changesets).toEqual([]) + expect(readResponse(relay.published, request.id, 'nip44_v2')).toBeNull() + await stop() + }) +}) diff --git a/src/lnurlcash/nwc.pay-b.cases.ts b/src/lnurlcash/nwc.pay-b.cases.ts new file mode 100644 index 0000000..30f30cf --- /dev/null +++ b/src/lnurlcash/nwc.pay-b.cases.ts @@ -0,0 +1,158 @@ +// 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: pay_invoice (continued)', () => { + it('rejects a payment over the connection budget with QUOTA_EXCEEDED', async () => { + const m = await mint() + const {relay, walletServicePubkey, state, stop} = await startTestService({ + budgetMsat: 20_000, + }) + state.bearers = [await makeBearer(m, 'ee'.repeat(32), 21_000)] + + const response = await call(relay, walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc210n1pjqrstuvwxyz', + }) + expect(response.error?.code).toBe('QUOTA_EXCEEDED') + // nothing moved: the note is untouched, no spend recorded + expect(m.state.noteState('ee'.repeat(32))).toBe('outstanding') + expect(requiredValue(state.bearers[0]).spent).toBeUndefined() + expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(0) + await stop() + }) + + it('resets the allowance once the budget period has rolled over', async () => { + const m = await mint() + const {relay, walletServicePubkey, state, stop} = await startTestService({ + budgetMsat: 21_000, + periodMs: 60_000, + }) + // simulate a fully spent budget from a period that ended long ago + const record: NwcConnectionRecord = requiredValue(readNwcConnections(OWNER_ID)[0]) + writeNwcConnections(OWNER_ID, [ + {...record, spent: {periodStart: Date.now() - 120_000, msat: 21_000}}, + ]) + state.bearers = [await makeBearer(m, 'ef'.repeat(32), 21_000)] + + const response = await call(relay, walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc210n1pjqrstuvwxyz', + }) + expect(response.error).toBeNull() + expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(21_000) + await stop() + }) + + it('rejects a payment the wallet cannot cover with INSUFFICIENT_BALANCE', async () => { + const m = await mint() + const {relay, walletServicePubkey, state, stop} = await startTestService({}) + state.bearers = [await makeBearer(m, 'ff'.repeat(32), 5_000)] + + const response = await call(relay, walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc210n1pjqrstuvwxyz', + }) + expect(response.error?.code).toBe('INSUFFICIENT_BALANCE') + expect(m.state.noteState('ff'.repeat(32))).toBe('outstanding') + await stop() + }) + + it('rejects a request amount that mismatches the invoice amount', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const response = await call(relay, walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc210n1pjqrstuvwxyz', + amount: 5_000, + }) + expect(response.error?.code).toBe('OTHER') + expect(response.error?.message).toMatch(/match/i) + await stop() + }) + + it('rejects an amount-less invoice instead of guessing', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const response = await call(relay, walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc1pjqrstuvwxyz', + }) + expect(response.error?.code).toBe('OTHER') + expect(response.error?.message).toMatch(/amount/i) + await stop() + }) + + it('answers a failed melt with PAYMENT_FAILED and tracks the returned funds', async () => { + const m = await mint({meltAlwaysFails: true}) + const {relay, walletServicePubkey, state, stop} = await startTestService({ + // a short verify budget: the failed melt is classified by the poll + // running out, and that wait is the test's own clock + poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}, + }) + state.bearers = [await makeBearer(m, '01'.repeat(32), 21_000)] + + const response = await call(relay, walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc210n1pjqrstuvwxyz', + }) + expect(response.error?.code).toBe('PAYMENT_FAILED') + + // the funds came back, re-secured: the old secret burned, a fresh one + // tracked unspent via the changeset - and no budget spend recorded + expect(m.state.noteState('01'.repeat(32))).toBe('burned') + const returned = requiredValue(state.bearers.find((b) => b.id.startsWith('added-'))) + expect(returned.spent).toBeUndefined() + expect(returned.amount).toBe(21_000) + expect(m.state.noteState(requiredValue(noteK1(returned.url)))).toBe('outstanding') + expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(0) + await stop() + }) +}) diff --git a/src/lnurlcash/nwc/context.ts b/src/lnurlcash/nwc/context.ts index 9541cfb..1e5148f 100644 --- a/src/lnurlcash/nwc/context.ts +++ b/src/lnurlcash/nwc/context.ts @@ -30,11 +30,13 @@ export type NwcServiceDeps = { // the mint make_invoice issues invoices against (the wallet's default // mint - NIP-47's make_invoice carries no mint choice) getDefaultMint: () => string | null + assertCurrentOwner: () => void applyChangeset: ( changeset: NwcChangeset, connection: NwcConnectionInfo, - method: NwcMethod - ) => void + method: NwcMethod, + assertOwner: () => void, + ) => Promise transport?: NwcTransport // kit transport overrides (fetch injection, timeouts) kit?: LnurlcashOptions @@ -73,4 +75,13 @@ export type RequestContext = { updateRecord: (record: NwcConnectionRecord) => void invoices: Map nowSeconds: () => number + assertOwner: () => void + // Starts service-owned work only while the service accepts new work. + // Accepted tasks become part of stop's drain before key cleanup. + startBackground: (work: () => Promise) => boolean + // fires when the service stops: long OBSERVATION waits (the invoice + // claim poll) must interrupt themselves on it. Work that has already + // reached a fund-critical commit must NOT consult it - stop awaits + // those tasks through its drain + stopSignal: AbortSignal } diff --git a/src/lnurlcash/nwc/pay.ts b/src/lnurlcash/nwc/pay.ts index b3d2192..c03cbda 100644 --- a/src/lnurlcash/nwc/pay.ts +++ b/src/lnurlcash/nwc/pay.ts @@ -9,7 +9,7 @@ import { decodeBolt11AmountMsat, fetchInvoiceVerification, isBolt11Invoice, - noteK1 + noteK1, } from 'lnurlcash-kit' import type {Bearer, NewBearer} from '../types' @@ -29,12 +29,9 @@ import {errResult, okResult} from './protocol' // rotate) is left for the next refresh to reconcile, exactly as the UI // leaves it - the money itself sits in the re-secured note, which IS // tracked. -export const payChangeset = ( - bearers: Bearer[], - result: PayResult -): NwcChangeset => { +export const payChangeset = (bearers: Bearer[], result: PayResult): NwcChangeset => { const add: NewBearer[] = [] - const markSpent: string[] = result.carve.consumed.map(b => b.id) + const markSpent: string[] = result.carve.consumed.map((b) => b.id) if (result.carve.change) add.push(result.carve.change) if (result.outcome === 'failed-funds-returned') { add.push(result.carve.note) @@ -44,9 +41,7 @@ export const payChangeset = ( // carve), lock that bearer; a freshly carved note is never added - // it was born spent const carvedK1 = noteK1(result.carve.note.url) - const existing = carvedK1 - ? bearers.find(b => noteK1(b.url) === carvedK1) - : undefined + const existing = carvedK1 ? bearers.find((b) => noteK1(b.url) === carvedK1) : undefined if (existing) markSpent.push(existing.id) } if (result.rescuedNote) add.push(result.rescuedNote) @@ -55,10 +50,9 @@ export const payChangeset = ( export const handlePayInvoice = async ( ctx: RequestContext, - params: Record + params: Record, ): Promise => { - const invoice = - typeof params.invoice === 'string' ? params.invoice.trim() : '' + const invoice = typeof params.invoice === 'string' ? params.invoice.trim() : '' if (!isBolt11Invoice(invoice)) { return errResult('pay_invoice', 'OTHER', 'Missing or invalid bolt11 invoice.') } @@ -69,23 +63,17 @@ export const handlePayInvoice = async ( return errResult( 'pay_invoice', 'OTHER', - '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.", ) } if (params.amount !== undefined && params.amount !== amountMsat) { - return errResult( - 'pay_invoice', - 'OTHER', - 'The request\'s amount does not match the invoice.' - ) + return errResult('pay_invoice', 'OTHER', "The request's amount does not match the invoice.") } - if ( - amountMsat > budgetRemainingMsat(ctx.connection().record, Date.now()) - ) { + if (amountMsat > budgetRemainingMsat(ctx.connection().record, Date.now())) { return errResult( 'pay_invoice', 'QUOTA_EXCEEDED', - 'This payment exceeds the connection\'s budget.' + "This payment exceeds the connection's budget.", ) } const bearers = ctx.deps.getBearers() @@ -93,77 +81,100 @@ export const handlePayInvoice = async ( try { result = await payWithBearers(bearers, invoice, { poll: ctx.deps.poll ?? {}, - kit: ctx.deps.kit ?? {} + kit: ctx.deps.kit ?? {}, + assertOwner: ctx.assertOwner, }) } catch (err) { if (err instanceof UncertainOutcomeError) { // the carve's answer was lost and the probe couldn't tell: the // possible outputs carry fresh secrets that may be the only money // left - tracked unverified, never dropped - ctx.deps.applyChangeset( + await ctx.deps.applyChangeset( {add: err.possibleOutputs, markSpent: []}, ctx.connection(), - 'pay_invoice' + 'pay_invoice', + ctx.assertOwner, ) return errResult( 'pay_invoice', 'INTERNAL', - 'The payment preparation could not be confirmed; possible new notes were stored unverified.' + 'The payment preparation could not be confirmed; possible new notes were stored unverified.', ) } const message = err instanceof Error ? err.message : String(err) return errResult( 'pay_invoice', /enough/i.test(message) ? 'INSUFFICIENT_BALANCE' : 'INTERNAL', - message + message, ) } const spendRecorded = (): void => { - ctx.updateRecord(recordSpend(ctx.connection().record, amountMsat, Date.now())) + // This conservative budget debit is persisted separately from bearer + // storage. A later bearer commit failure does not roll it back. + const connection = ctx.connection().record + ctx.updateRecord(recordSpend(connection.ownerId, connection, amountMsat, Date.now())) } switch (result.outcome) { case 'settled': { spendRecorded() - ctx.deps.applyChangeset(payChangeset(bearers, result), ctx.connection(), 'pay_invoice') + await ctx.deps.applyChangeset( + payChangeset(bearers, result), + ctx.connection(), + 'pay_invoice', + ctx.assertOwner, + ) // the receipt NIP-47 clients expect: the melt's own payment // preimage, re-read from the settle proof. A mint that reveals // none yields an empty preimage rather than a fabricated one. let preimage = '' if (result.verifyUrl) { try { - const proof = await fetchInvoiceVerification( - result.verifyUrl, - ctx.deps.kit ?? {} - ) + const proof = await fetchInvoiceVerification(result.verifyUrl, ctx.deps.kit ?? {}) preimage = proof.preimage ?? '' - } catch { + } catch (error) { // the settle proof was already polled inside payWithBearers; // a failed re-read must not flip the outcome + if (!(error instanceof Error)) throw error } } return okResult('pay_invoice', {preimage}) } case 'failed-funds-returned': - ctx.deps.applyChangeset(payChangeset(bearers, result), ctx.connection(), 'pay_invoice') + await ctx.deps.applyChangeset( + payChangeset(bearers, result), + ctx.connection(), + 'pay_invoice', + ctx.assertOwner, + ) return errResult( 'pay_invoice', 'PAYMENT_FAILED', - 'The payment failed; the funds are back in the wallet.' + 'The payment failed; the funds are back in the wallet.', ) case 'note-already-spent': - ctx.deps.applyChangeset(payChangeset(bearers, result), ctx.connection(), 'pay_invoice') + await ctx.deps.applyChangeset( + payChangeset(bearers, result), + ctx.connection(), + 'pay_invoice', + ctx.assertOwner, + ) return errResult( 'pay_invoice', 'PAYMENT_FAILED', - 'The note backing this payment was already spent; nothing was paid.' + 'The note backing this payment was already spent; nothing was paid.', ) case 'unknown-still-pending': spendRecorded() - ctx.deps.applyChangeset(payChangeset(bearers, result), ctx.connection(), 'pay_invoice') + await ctx.deps.applyChangeset( + payChangeset(bearers, result), + ctx.connection(), + 'pay_invoice', + ctx.assertOwner, + ) return errResult( 'pay_invoice', 'OTHER', - 'The payment is still in flight; the note stays locked until it reconciles.' + 'The payment is still in flight; the note stays locked until it reconciles.', ) } }