diff --git a/src/lnurlcash/nwc.invoice-a.cases.ts b/src/lnurlcash/nwc.invoice-a.cases.ts index 109e548..456878b 100644 --- a/src/lnurlcash/nwc.invoice-a.cases.ts +++ b/src/lnurlcash/nwc.invoice-a.cases.ts @@ -57,8 +57,23 @@ import { waitFor, } from './nwc.testProtocol' import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService' +import {invoiceRetireAfter} from './nwc/invoices' + +export const SIGNED_BOLT11_FIXTURE = + 'lnbc250n1p4f4d2ysp5s7pqe2590jtlgpqyzw82m4xtn5wpkehf8jt4fcj754p4s2nf3r3qpp5d2zdf3satrdk5qllx00hwah5tlg69h90w5s78flfg8ffq4fagalsdqhd3h82unvvdshx6pqd45kuaqxqyjw5qcqpjrzjqwda3lfx6jhqd33847rqkfclpnyfv9f74pavem0mtvhdyzlam4lnkr83hqqq55cqqyqqqqqqqqqqzsqq9q9qxpqysgql3u9h88pw0pnfqdqh5hdgstgc8gazln7xeleuu7elmw7ea6q488js0mqwchtcq7rphv9uc3q5yqllx6ksdw8yjfq5e9jcgarlqzrk6gq5ksjdz' + describe('service: make_invoice / lookup_invoice', () => { - it('issues an invoice, settles it in the background, and reports the preimage', async () => { + it('derives a safe retirement time from a signed BOLT11 invoice', () => { + expect(invoiceRetireAfter(SIGNED_BOLT11_FIXTURE)).toBeGreaterThan(0) + }) + + it('rejects a retirement time outside the safe integer range', () => { + const timestamp = 'q'.repeat(7) + const expiryTag = `xqv${'l'.repeat(12)}` + expect(invoiceRetireAfter(`lnbc1${timestamp}${expiryTag}qqqqqq`)).toBeNull() + }) + + it('stages an output, 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}`, @@ -88,7 +103,9 @@ describe('service: make_invoice / lookup_invoice', () => { } expect(invoice).toMatch(/^lnbc/) expect(paymentHash).toMatch(/^[0-9a-f]{64}$/) - + const staged = requiredValue(state.bearers.find((bearer) => bearer.id.startsWith('staged-'))) + expect(staged.verified).toBe(false) + expect(staged.callback).toBe('') // before settlement the lookup reports the pending invoice const pending = await call(relay, walletServicePubkey, 'lookup_invoice', { payment_hash: paymentHash, @@ -101,7 +118,7 @@ describe('service: make_invoice / lookup_invoice', () => { // 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)) + await waitFor(() => state.bearers.some((bearer) => bearer.verified)) const settled = await call(relay, walletServicePubkey, 'lookup_invoice', { payment_hash: paymentHash, @@ -112,11 +129,10 @@ describe('service: make_invoice / lookup_invoice', () => { 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-'))) + // The staged record was updated in place before settlement became + // visible. The payment preimage is only a receipt and keys no note. + expect(m.state.noteState(preimage)).toBeNull() + const minted = requiredValue(state.bearers.find((b) => b.id === staged.id)) expect(minted.amount).toBe(21_000) expect(minted.verified).toBe(true) expect(noteK1(minted.url)).not.toBe(preimage) @@ -124,129 +140,6 @@ 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.invoice-b.cases.ts b/src/lnurlcash/nwc.invoice-b.cases.ts index 115e22a..80d8a7c 100644 --- a/src/lnurlcash/nwc.invoice-b.cases.ts +++ b/src/lnurlcash/nwc.invoice-b.cases.ts @@ -57,6 +57,7 @@ import { waitFor, } from './nwc.testProtocol' import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService' +import {SIGNED_BOLT11_FIXTURE} from './nwc.invoice-a.cases' 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}) @@ -230,6 +231,99 @@ describe('service: make_invoice / lookup_invoice (continued)', () => { await stop() }) + it('rejects invoice creation when the durable pending-output cap is reached', async () => { + const {relay, walletServicePubkey, state, stop} = await startTestService({ + defaultMint: 'mint@example.com', + }) + for (let index = 0; index < 20; index += 1) { + state.bearers.push({ + id: `pending-${index}`, + url: `https://mint.example/w?k1=${index.toString(16).padStart(64, '0')}`, + callback: '', + amount: 0, + verified: false, + pendingMint: {}, + createdAt: index, + updatedAt: index, + }) + } + + const response = await call(relay, walletServicePubkey, 'make_invoice', { + amount: 21_000, + }) + + expect(response.error?.code).toBe('QUOTA_EXCEEDED') + expect(state.bearers).toHaveLength(20) + await stop() + }) + + it('removes staged outputs when invoice preparation fails before publication', async () => { + const m = await mint({testHooks: true}) + let rejectQuotes = true + const {relay, walletServicePubkey, state, stop} = await startTestService({ + defaultMint: `mint@127.0.0.1:${m.port}`, + kit: { + fetch: (input, init) => { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input.url + if (rejectQuotes && url.includes('comment=')) { + return Promise.resolve(new Response('quote failed', {status: 500})) + } + return fetch(input, init) + }, + }, + }) + + for (let attempt = 0; attempt < 20; attempt += 1) { + const failed = await call(relay, walletServicePubkey, 'make_invoice', {amount: 21_000}) + expect(failed.error?.code).toBe('INTERNAL') + expect(state.bearers).toHaveLength(0) + } + + rejectQuotes = false + const made = await call(relay, walletServicePubkey, 'make_invoice', {amount: 21_000}) + expect(made.error).toBeNull() + expect(state.bearers).toHaveLength(1) + await stop() + }) + + it('removes an unpublished stage when retirement persistence fails', async () => { + const m = await mint({testHooks: true}) + const {relay, walletServicePubkey, state, stop} = await startTestService({ + defaultMint: `mint@127.0.0.1:${m.port}`, + setMintOutputRetirement: () => Promise.reject(new Error('retirement write failed')), + kit: { + fetch: (input, init) => { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input.url + if (url.includes('comment=')) { + return Promise.resolve( + new Response(JSON.stringify({pr: SIGNED_BOLT11_FIXTURE}), { + status: 200, + headers: {'content-type': 'application/json'}, + }), + ) + } + return fetch(input, init) + }, + }, + }) + + const response = await call(relay, walletServicePubkey, 'make_invoice', {amount: 25_000}) + + expect(response.error?.code).toBe('INTERNAL') + expect(state.bearers).toHaveLength(0) + 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', { diff --git a/src/lnurlcash/nwc.ownership.cases.ts b/src/lnurlcash/nwc.ownership.cases.ts index 8b1f154..57c298a 100644 --- a/src/lnurlcash/nwc.ownership.cases.ts +++ b/src/lnurlcash/nwc.ownership.cases.ts @@ -71,8 +71,19 @@ describe('service ownership', () => { const service = await startService(LINKING_KEY, { assertCurrentOwner: () => undefined, + allocateOutputSecrets: () => Promise.resolve([]), + mintSignatureKeys: () => [], + recoverPendingMints: () => Promise.resolve(), + reserveMintOutputSource: () => Promise.resolve(), + finalizePaymentReturn: () => Promise.resolve(), getBearers: () => [], getDefaultMint: () => null, + persistMintOutput: () => Promise.reject(new Error('unused')), + discardMintOutput: () => Promise.reject(new Error('unused')), + setMintOutputRetirement: () => Promise.reject(new Error('unused')), + finalizeMintOutput: () => Promise.reject(new Error('unused')), + finalizeSpentMintOutput: () => Promise.reject(new Error('unused')), + commitCarve: () => Promise.reject(new Error('unused')), applyChangeset: () => Promise.resolve(), transport: relay.transport, nowSeconds, @@ -102,8 +113,19 @@ describe('service ownership', () => { LINKING_KEY, { assertCurrentOwner: () => undefined, + allocateOutputSecrets: () => Promise.resolve([]), + mintSignatureKeys: () => [], + recoverPendingMints: () => Promise.resolve(), + reserveMintOutputSource: () => Promise.resolve(), + finalizePaymentReturn: () => Promise.resolve(), getBearers: () => [], getDefaultMint: () => null, + persistMintOutput: () => Promise.reject(new Error('unused')), + discardMintOutput: () => Promise.reject(new Error('unused')), + setMintOutputRetirement: () => Promise.reject(new Error('unused')), + finalizeMintOutput: () => Promise.reject(new Error('unused')), + finalizeSpentMintOutput: () => Promise.reject(new Error('unused')), + commitCarve: () => Promise.reject(new Error('unused')), applyChangeset: () => Promise.resolve(), transport: relay.transport, nowSeconds, diff --git a/src/lnurlcash/nwc.pay-b.cases.ts b/src/lnurlcash/nwc.pay-b.cases.ts index 30f30cf..5465ae2 100644 --- a/src/lnurlcash/nwc.pay-b.cases.ts +++ b/src/lnurlcash/nwc.pay-b.cases.ts @@ -138,21 +138,38 @@ describe('service: pay_invoice (continued)', () => { // 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 original = await makeBearer(m, '01'.repeat(32), 21_000) + state.bearers = [original] 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(state.bearers.find((bearer) => bearer.id === original.id)?.spent).toBe(true) + expect( + state.bearers.find((bearer) => bearer.id !== original.id && bearer.amount === 21_000 && !bearer.spent), + ).toBeDefined() + expect(state.bearers.filter((bearer) => !bearer.spent).reduce((sum, bearer) => sum + bearer.amount, 0)).toBe(21_000) expect(requiredValue(readNwcConnections(OWNER_ID)[0]).spent.msat).toBe(0) await stop() }) + + it('conserves balance when a split payment fails and returns its carved funds', async () => { + const m = await mint({meltAlwaysFails: true}) + const {relay, walletServicePubkey, state, stop} = await startTestService({ + poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}, + }) + state.bearers = [await makeBearer(m, '02'.repeat(32), 30_000)] + + const response = await call(relay, walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc210n1pjqrstuvwxyz', + }) + + expect(response.error?.code).toBe('PAYMENT_FAILED') + expect(state.bearers.filter((bearer) => !bearer.spent).reduce((sum, bearer) => sum + bearer.amount, 0)).toBe(30_000) + expect(state.bearers.find((bearer) => bearer.amount === 21_000 && !bearer.spent)).toBeDefined() + await stop() + }) }) diff --git a/src/lnurlcash/nwc.testService.ts b/src/lnurlcash/nwc.testService.ts index 4036e0e..197b0a1 100644 --- a/src/lnurlcash/nwc.testService.ts +++ b/src/lnurlcash/nwc.testService.ts @@ -7,11 +7,12 @@ // already-rotated (burned) note secret. import {afterEach, beforeEach, describe, expect, it} from 'vitest' -import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js' +import {sha256} from '@noble/hashes/sha2.js' +import {bytesToHex, hexToBytes, utf8ToBytes} 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, noteK1, serverOf} from 'lnurlcash-kit' import {createMockMint} from 'lnurlcash-conformance/mock-mint' import { @@ -114,6 +115,7 @@ afterEach(async () => { }) let bearerCounter = 0 +let allocationCounter = 0 export const makeBearer = async (m: Mint, k1: string, amountMsat: number): Promise => { m.state.creditNote(k1, amountMsat) const url = buildNoteUrl(`${m.url}/w`, k1, amountMsat) @@ -143,6 +145,7 @@ export const startTestService = async (options: { claimPoll?: typeof FAST_POLL kit?: NwcServiceDeps['kit'] commitChangeset?: (changeset: NwcChangeset) => Promise + setMintOutputRetirement?: () => Promise }): Promise<{ relay: ReturnType walletServicePubkey: string @@ -164,8 +167,120 @@ export const startTestService = async (options: { } const service = await startService(options.linkingKey ?? LINKING_KEY, { assertCurrentOwner: () => undefined, + // deterministic stand-in for the wallet's BIP-32 allocation path + allocateOutputSecrets: (_server, count) => { + const secrets = Array.from({length: count}, () => { + allocationCounter += 1 + return bytesToHex(sha256(utf8ToBytes(`nwc-test-allocation-${allocationCounter}`))) + }) + return Promise.resolve(secrets) + }, + // the keys a production caller serves from the trusted-mint registry: + // each created mock mint's current key plus any it retired + mintSignatureKeys: (server) => + mints.flatMap((m) => + new URL(m.url).host === server ? [m.state.pubkey, ...m.state.previousPubkeys] : [], + ), + recoverPendingMints: () => Promise.resolve(), getBearers: () => state.bearers, getDefaultMint: () => options.defaultMint ?? null, + persistMintOutput: (note) => { + bearerCounter += 1 + const bearer: Bearer = { + ...note, + id: `staged-${bearerCounter}`, + createdAt: Date.now(), + updatedAt: Date.now(), + } + state.bearers.push(bearer) + return Promise.resolve(bearer) + }, + discardMintOutput: (staged) => { + state.bearers = state.bearers.filter((bearer) => bearer.id !== staged.id) + return Promise.resolve() + }, + setMintOutputRetirement: (staged, retireAfter) => { + const override = options.setMintOutputRetirement?.() + if (override) return override + const index = state.bearers.findIndex((bearer) => bearer.id === staged.id) + if (index < 0) return Promise.reject(new Error('The staged bearer is missing.')) + const updated: Bearer = { + ...staged, + pendingMint: {...staged.pendingMint, retireAfter}, + updatedAt: Date.now(), + } + state.bearers[index] = updated + return Promise.resolve() + }, + reserveMintOutputSource: (staged, sourceId, recoverySecret) => { + const index = state.bearers.findIndex((bearer) => bearer.id === staged.id) + const source = state.bearers.find((bearer) => bearer.id === sourceId) + if (index < 0 || !source) return Promise.reject(new Error('The payment journal is missing.')) + source.spent = true + state.bearers[index] = { + ...state.bearers[index]!, + pendingMint: { + ...state.bearers[index]!.pendingMint, + sourceBearerId: sourceId, + sourceRecoverySecret: recoverySecret, + }, + } + return Promise.resolve() + }, + finalizeMintOutput: async (staged, note) => { + const changeset: NwcChangeset = {add: [], markSpent: []} + await options.commitChangeset?.(changeset) + state.changesets.push(changeset) + const index = state.bearers.findIndex((bearer) => bearer.id === staged.id) + if (index < 0) throw new Error('The staged bearer is missing.') + state.bearers[index] = {...staged, ...note, pendingMint: undefined, updatedAt: Date.now()} + }, + finalizePaymentReturn: async (staged, note) => { + const index = state.bearers.findIndex((bearer) => bearer.id === staged.id) + if (index < 0) throw new Error('The staged bearer is missing.') + const sourceId = state.bearers[index]?.pendingMint?.sourceBearerId + if (sourceId) { + const source = state.bearers.find((bearer) => bearer.id === sourceId) + if (source) source.spent = true + } + state.bearers[index] = {...state.bearers[index]!, ...note, pendingMint: undefined, updatedAt: Date.now()} + }, + finalizeSpentMintOutput: (staged) => { + state.bearers = state.bearers.filter((bearer) => bearer.id !== staged.id) + return Promise.resolve() + }, + commitCarve: async (carve) => { + // a checkpoint is identified by its output secret: the landed phase's + // URL carries a signature the staged phase's did not have yet + const sameOutput = (url: string, noteUrl: string): boolean => + serverOf(url) === serverOf(noteUrl) && + noteK1(url) !== null && + noteK1(url) === noteK1(noteUrl) + const additions = [carve.note, ...(carve.change ? [carve.change] : [])].filter( + (note) => !state.bearers.some((bearer) => sameOutput(bearer.url, note.url)), + ) + const changeset: NwcChangeset = { + add: additions, + markSpent: carve.consumed.map((bearer) => bearer.id), + } + await options.commitChangeset?.(changeset) + state.changesets.push(changeset) + for (const bearer of state.bearers) { + if (changeset.markSpent.includes(bearer.id)) bearer.spent = true + } + for (const note of additions) { + bearerCounter += 1 + state.bearers.unshift({ + ...note, + id: `carve-${bearerCounter}`, + createdAt: Date.now(), + updatedAt: Date.now(), + }) + } + const committed = state.bearers.find((bearer) => sameOutput(bearer.url, carve.note.url)) + if (!committed) throw new Error('The carved note was not committed.') + return committed + }, applyChangeset: async (changeset: NwcChangeset) => { await options.commitChangeset?.(changeset) state.changesets.push(changeset) diff --git a/src/lnurlcash/nwc/context.ts b/src/lnurlcash/nwc/context.ts index 1e5148f..4414447 100644 --- a/src/lnurlcash/nwc/context.ts +++ b/src/lnurlcash/nwc/context.ts @@ -10,6 +10,9 @@ import type {LnurlcashOptions} from 'lnurlcash-kit' import type {Bearer, NewBearer} from '../types' import type {NwcConnectionRecord} from '../storage/nwcConnections' import type {PreparedMint} from '../ops' +import type {CarveResult} from '../ops' +import type {OutputSecretAllocator} from '../ops/allocation' +import type {MintSignatureKeys} from '../ops/shared' import type {PollOptions} from '../ops/shared' import type {NwcConnectionInfo} from './connection' @@ -31,6 +34,38 @@ export type NwcServiceDeps = { // mint - NIP-47's make_invoice carries no mint choice) getDefaultMint: () => string | null assertCurrentOwner: () => void + // the wallet's durable allocation path (BIP-32 reservation) for every + // note output secret a request stages + allocateOutputSecrets: ( + server: string, + count: number, + assertOwner: () => void, + ) => Promise + // trusted current+previous signing keys a landed carve output verifies + // against - the trusted-mint registry, owner-scoped + mintSignatureKeys: MintSignatureKeys + recoverPendingMints: (assertOwner: () => void) => Promise + persistMintOutput: (note: NewBearer, assertOwner: () => void) => Promise + discardMintOutput: (staged: Bearer, assertOwner: () => void) => Promise + setMintOutputRetirement: ( + staged: Bearer, + retireAfter: number, + assertOwner: () => void, + ) => Promise + reserveMintOutputSource: ( + staged: Bearer, + sourceId: string, + recoverySecret: string, + assertOwner: () => void, + ) => Promise + finalizeMintOutput: (staged: Bearer, note: NewBearer, assertOwner: () => void) => Promise + finalizePaymentReturn: ( + staged: Bearer, + note: NewBearer, + assertOwner: () => void, + ) => Promise + finalizeSpentMintOutput: (staged: Bearer, assertOwner: () => void) => Promise + commitCarve: (carve: CarveResult, assertOwner: () => void) => Promise applyChangeset: ( changeset: NwcChangeset, connection: NwcConnectionInfo, @@ -61,6 +96,7 @@ export type PendingInvoice = { createdAt: number // unix seconds expiresAt?: number prepared: PreparedMint + stagedOutput: Bearer state: 'pending' | 'settled' | 'failed' preimage?: string settledAt?: number diff --git a/src/lnurlcash/nwc/dispatch.ts b/src/lnurlcash/nwc/dispatch.ts index 99c06f8..0ed69e3 100644 --- a/src/lnurlcash/nwc/dispatch.ts +++ b/src/lnurlcash/nwc/dispatch.ts @@ -12,11 +12,13 @@ import {prepareMint} from '../ops' import {PollAbortedError} from '../ops/shared' import type {PendingInvoice, RequestContext} from './context' -import {invoiceResult, resolvePaymentHash, settleAndClaim} from './invoices' +import {invoiceResult, invoiceRetireAfter, resolvePaymentHash, settleAndClaim} from './invoices' import {handlePayInvoice} from './pay' import type {NwcRequest, NwcResponse} from './protocol' import {NWC_METHODS, errResult, okResult} from './protocol' +const MAX_PENDING_MINT_OUTPUTS = 20 + // the same eligibility carve applies - the balance answers "what could // this wallet actually pay with right now" const spendable = (bearer: Bearer): boolean => @@ -55,18 +57,86 @@ const handleMakeInvoice = async ( if (!mint) { return errResult('make_invoice', 'INTERNAL', 'No default mint is configured.') } + const now = ctx.nowSeconds() + if ( + ctx.deps + .getBearers() + .some( + (bearer) => + !bearer.spent && + bearer.pendingMint?.retireAfter !== undefined && + bearer.pendingMint.retireAfter <= now, + ) + ) { + await ctx.deps.recoverPendingMints(ctx.assertOwner) + } + const pendingOutputs = ctx.deps + .getBearers() + .filter((bearer) => bearer.pendingMint && !bearer.spent).length + if (pendingOutputs >= MAX_PENDING_MINT_OUTPUTS) { + return errResult('make_invoice', 'QUOTA_EXCEEDED', 'Too many unpaid invoices are pending.') + } let prepared: PreparedMint + let stagedOutput: Bearer | undefined try { - prepared = await prepareMint(mint, amountMsat, ctx.deps.kit ?? {}) + prepared = await prepareMint(mint, amountMsat, { + ...(ctx.deps.kit ?? {}), + assertOwner: ctx.assertOwner, + allocateOutputSecrets: (server, count) => + ctx.deps.allocateOutputSecrets(server, count, ctx.assertOwner), + persistOutput: async (note) => { + stagedOutput = await ctx.deps.persistMintOutput(note, ctx.assertOwner) + }, + }) } catch (err) { + if (stagedOutput) { + try { + await ctx.deps.discardMintOutput(stagedOutput, ctx.assertOwner) + } catch (cleanupError) { + return errResult( + 'make_invoice', + 'INTERNAL', + cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + ) + } + } return errResult('make_invoice', 'INTERNAL', err instanceof Error ? err.message : String(err)) } + if (!stagedOutput) { + return errResult('make_invoice', 'INTERNAL', 'The pending mint output was not persisted.') + } + const retireAfter = invoiceRetireAfter(prepared.invoice) + if (retireAfter !== null) { + try { + await ctx.deps.setMintOutputRetirement( + stagedOutput, + retireAfter, + ctx.assertOwner, + ) + } catch (error) { + try { + await ctx.deps.discardMintOutput(stagedOutput, ctx.assertOwner) + } catch (cleanupError) { + return errResult( + 'make_invoice', + 'INTERNAL', + cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + ) + } + return errResult( + 'make_invoice', + 'INTERNAL', + error instanceof Error ? error.message : String(error), + ) + } + } const entry: PendingInvoice = { invoice: prepared.invoice, paymentHash: resolvePaymentHash(prepared), amountMsat: prepared.grossMsat, createdAt: ctx.nowSeconds(), prepared, + stagedOutput, state: 'pending', } if (typeof params.description === 'string' && params.description) { diff --git a/src/lnurlcash/nwc/invoices.ts b/src/lnurlcash/nwc/invoices.ts index e1a8caa..c684d27 100644 --- a/src/lnurlcash/nwc/invoices.ts +++ b/src/lnurlcash/nwc/invoices.ts @@ -1,26 +1,16 @@ // 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, and only THEN records the settlement the lookup -// reports. -// -// 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. +// shares. A minted note always lives at the wallet-chosen secret that was +// durably staged before quote creation; the payment preimage is only a +// receipt. Settlement becomes visible only after the staged bearer has +// been replaced with the confirmed note details. import {isPreimage, sameInvoice} from 'lnurlcash-kit' import {sha256} from '@noble/hashes/sha2.js' import {bytesToHex, utf8ToBytes} from '@noble/hashes/utils.js' -import type {NewBearer} from '../types' import type {PreparedMint} from '../ops' -import {claimFromPreimage, claimFromSecret} from '../ops/mint' +import {MintedNoteSpentError} from '../ops' +import {claimFromSecret} from '../ops/mint' import {pollVerifyUntilSettled} from '../ops/shared' import type {PollOptions} from '../ops/shared' @@ -32,18 +22,56 @@ export const DEFAULT_CLAIM_POLL: Required> = { maxWaitMs: 15 * 60_000, } -// LUD-21 verify URLs end in /verify/ (the protocol's verify -// convention) - that suffix is the invoice's real payment hash. A service -// that shapes its verify URL differently gets a wallet-local correlation -// id instead (sha256 of the invoice): still unique and stable for -// make_invoice <-> lookup_invoice correlation, just not the on-chain hash. +const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l' +const BOLT11_EXPIRY_TAG = BECH32_CHARSET.indexOf('x') +const BOLT11_DEFAULT_EXPIRY_SECONDS = 3600 +const SETTLEMENT_GRACE_SECONDS = 14 * 24 * 60 * 60 + +// The kit has already validated the invoice before this parser runs. Read +// only BOLT11's timestamp and optional expiry tag; an unfamiliar shape stays +// durable forever rather than risking retirement while it could still settle. +export const invoiceRetireAfter = (invoice: string): number | null => { + const normalized = invoice.toLowerCase() + const separator = normalized.lastIndexOf('1') + if (separator < 1 || normalized.length - separator <= 6) return null + const words = [...normalized.slice(separator + 1, -6)].map((character) => + BECH32_CHARSET.indexOf(character), + ) + if (words.length < 7 || words.some((word) => word < 0)) return null + const timestamp = words.slice(0, 7).reduce((value, word) => value * 32 + word, 0) + if (!Number.isSafeInteger(timestamp)) return null + let expiry = BOLT11_DEFAULT_EXPIRY_SECONDS + let offset = 7 + // Conformance invoices omit the 65-byte signature; real BOLT11 invoices + // include its fixed 104 words after the tagged fields. + const taggedEnd = words.length >= 7 + 104 ? words.length - 104 : words.length + while (offset + 3 <= taggedEnd) { + const tag = words[offset] + const firstLength = words[offset + 1] + const secondLength = words[offset + 2] + if (tag === undefined || firstLength === undefined || secondLength === undefined) return null + const length = firstLength * 32 + secondLength + const valueStart = offset + 3 + const valueEnd = valueStart + length + if (valueEnd > taggedEnd) return null + if (tag === BOLT11_EXPIRY_TAG) { + expiry = words.slice(valueStart, valueEnd).reduce((value, word) => value * 32 + word, 0) + if (!Number.isSafeInteger(expiry)) return null + } + offset = valueEnd + } + const retireAfter = timestamp + expiry + SETTLEMENT_GRACE_SECONDS + return Number.isSafeInteger(retireAfter) ? retireAfter : null +} + +// LUD-21 verify URLs conventionally end in the payment hash. A service +// that uses another shape gets a stable wallet-local correlation id. export const resolvePaymentHash = (prepared: PreparedMint): string => { const fromVerify = prepared.verifyUrl?.match(/\/([0-9a-f]{64})$/i)?.[1] if (fromVerify) return fromVerify.toLowerCase() return bytesToHex(sha256(utf8ToBytes(prepared.invoice))) } -// the NIP-47 transaction object make_invoice and lookup_invoice share export const invoiceResult = (entry: PendingInvoice): Record => { const result: Record = { type: 'incoming', @@ -56,89 +84,18 @@ export const invoiceResult = (entry: PendingInvoice): Record => } if (entry.description) result.description = entry.description if (entry.expiresAt) result.expires_at = entry.expiresAt - if (entry.state === 'settled' && entry.preimage) { - result.preimage = entry.preimage - } + if (entry.state === 'settled' && entry.preimage) result.preimage = entry.preimage if (entry.settledAt) result.settled_at = entry.settledAt return result } -// the background half of make_invoice: watch the invoice, and once it -// 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.', - ) - } - // 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), 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.") - } - const preimage = result.preimage - if (!preimage || !isPreimage(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 ?? {}), - 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) { - // see the header: only a rotated note makes the preimage a worthless - // secret, safe to hand out as the settlement receipt - 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) + // Observation remains interruptible. Once settlement is observed, the + // claim and durable update ignore shutdown while stop drains the task. const result = await pollVerifyUntilSettled( prepared.verifyUrl, {...claimPoll, signal: ctx.stopSignal}, @@ -149,30 +106,23 @@ const settleAndClaimNamed = async ( } 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, - ) + let claimed + try { + claimed = await claimFromSecret( + prepared, + prepared.verifyUrl ? claimPoll : {...claimPoll, signal: ctx.stopSignal}, + { + ...(ctx.deps.kit ?? {}), + assertOwner: ctx.assertOwner, + }, + ) + } catch (error) { + if (!(error instanceof MintedNoteSpentError)) throw error + await ctx.deps.finalizeSpentMintOutput(entry.stagedOutput, ctx.assertOwner) + throw error + } + await ctx.deps.finalizeMintOutput(entry.stagedOutput, claimed.note, 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 + if (preimage) entry.preimage = preimage } diff --git a/src/lnurlcash/nwc/pay.ts b/src/lnurlcash/nwc/pay.ts index d05ea4e..bdbf2ae 100644 --- a/src/lnurlcash/nwc/pay.ts +++ b/src/lnurlcash/nwc/pay.ts @@ -10,6 +10,7 @@ import { fetchInvoiceVerification, isBolt11Invoice, noteK1, + withNewK1, } from 'lnurlcash-kit' import type {Bearer, NewBearer} from '../types' @@ -33,19 +34,29 @@ import {errResult, okResult} from './protocol' export const payChangeset = (bearers: Bearer[], result: PayResult): NwcChangeset => { const add: NewBearer[] = [] const markSpent: string[] = result.carve.consumed.map((b) => b.id) - if (result.carve.change) add.push(result.carve.change) + // the staged change's URL may lack the signature the landed result's URL + // carries - identify outputs by their secret, not the whole URL + const changeK1 = result.carve.change ? noteK1(result.carve.change.url) : null + if ( + result.carve.change && + !bearers.some((bearer) => changeK1 !== null && noteK1(bearer.url) === changeK1) + ) { + add.push(result.carve.change) + } + const carvedK1 = noteK1(result.carve.note.url) + const existing = carvedK1 ? bearers.find((bearer) => noteK1(bearer.url) === carvedK1) : undefined if (result.outcome === 'failed-funds-returned') { - add.push(result.rotatedNote ?? result.carve.note) + if (existing) { + const index = markSpent.indexOf(existing.id) + if (index >= 0) markSpent.splice(index, 1) + } } else { // settled / still-pending / already-spent: the carved note is gone or // locked. When it was one of the wallet's own bearers (an exact-match // 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 if (existing) markSpent.push(existing.id) } - if (result.rescuedNote) add.push(result.rescuedNote) return {add, markSpent} } @@ -79,11 +90,40 @@ export const handlePayInvoice = async ( } const bearers = ctx.deps.getBearers() let result: PayResult + let committedCarve: Bearer | undefined + let returnStage: Bearer | undefined try { result = await payWithBearers(bearers, invoice, { poll: ctx.deps.poll ?? {}, kit: ctx.deps.kit ?? {}, assertOwner: ctx.assertOwner, + allocateOutputSecrets: (server, count) => + ctx.deps.allocateOutputSecrets(server, count, ctx.assertOwner), + mintSignatureKeys: ctx.deps.mintSignatureKeys, + onCarve: async (carve) => { + committedCarve = await ctx.deps.commitCarve(carve, ctx.assertOwner) + }, + onReturnReady: async (carve, recoverySecret) => { + const source = + committedCarve ?? ctx.deps.getBearers().find((bearer) => bearer.url === carve.note.url) + if (!source) throw new Error('The carved note was not tracked.') + returnStage = await ctx.deps.persistMintOutput( + { + url: withNewK1(carve.note.url, recoverySecret, carve.note.amount), + callback: '', + amount: 0, + verified: false, + pendingMint: {retireAfter: ctx.nowSeconds() + 14 * 24 * 60 * 60}, + }, + ctx.assertOwner, + ) + await ctx.deps.reserveMintOutputSource( + returnStage, + source.id, + recoverySecret, + ctx.assertOwner, + ) + }, }) } catch (err) { if (err instanceof UncertainOutcomeError) { @@ -109,6 +149,14 @@ export const handlePayInvoice = async ( message, ) } + if (returnStage) { + const returned = result.rotatedNote ?? result.rescuedNote + if (returned) { + await ctx.deps.finalizePaymentReturn(returnStage, returned, ctx.assertOwner) + } else if (result.outcome !== 'unknown-still-pending') { + await ctx.deps.finalizeSpentMintOutput(returnStage, ctx.assertOwner) + } + } const spendRecorded = (): void => { // This conservative budget debit is persisted separately from bearer // storage. A later bearer commit failure does not roll it back. @@ -119,7 +167,7 @@ export const handlePayInvoice = async ( case 'settled': { spendRecorded() await ctx.deps.applyChangeset( - payChangeset(bearers, result), + payChangeset(ctx.deps.getBearers(), result), ctx.connection(), 'pay_invoice', ctx.assertOwner, @@ -142,7 +190,7 @@ export const handlePayInvoice = async ( } case 'failed-funds-returned': await ctx.deps.applyChangeset( - payChangeset(bearers, result), + payChangeset(ctx.deps.getBearers(), result), ctx.connection(), 'pay_invoice', ctx.assertOwner, @@ -154,7 +202,7 @@ export const handlePayInvoice = async ( ) case 'note-already-spent': await ctx.deps.applyChangeset( - payChangeset(bearers, result), + payChangeset(ctx.deps.getBearers(), result), ctx.connection(), 'pay_invoice', ctx.assertOwner, @@ -167,7 +215,7 @@ export const handlePayInvoice = async ( case 'unknown-still-pending': spendRecorded() await ctx.deps.applyChangeset( - payChangeset(bearers, result), + payChangeset(ctx.deps.getBearers(), result), ctx.connection(), 'pay_invoice', ctx.assertOwner, diff --git a/src/lnurlcash/nwc/service.ts b/src/lnurlcash/nwc/service.ts index 2a43e72..9fee523 100644 --- a/src/lnurlcash/nwc/service.ts +++ b/src/lnurlcash/nwc/service.ts @@ -89,13 +89,24 @@ export const startService = async ( ) } - // pay_invoice goes through the connection's queue (budget atomicity); - // everything else dispatches directly + let invoiceQueue: Promise = Promise.resolve() + + // Payments serialize per connection for budget atomicity. Invoice + // creation serializes across the service so different clients cannot + // race the wallet-wide durable pending-output cap. const dispatchSerialized = ( runtime: ConnectionRuntime, ctx: RequestContext, request: NwcRequest, ): Promise => { + if (request.method === 'make_invoice') { + const run = invoiceQueue.then( + () => dispatch(ctx, request), + () => dispatch(ctx, request), + ) + invoiceQueue = run.catch(() => undefined) + return run + } if (request.method !== 'pay_invoice') return dispatch(ctx, request) const run = runtime.queue.then( () => dispatch(ctx, request), @@ -223,8 +234,12 @@ export const startService = async ( accepting = false for (const runtime of runtimes) runtime.sub.close() stopController.abort() - stopPromise = Promise.all([...inFlight]) - .then(() => undefined) + stopPromise = Promise.allSettled([...inFlight]) + .then((results) => { + for (const result of results) { + if (result.status === 'rejected') throw result.reason + } + }) .finally(() => { for (const runtime of runtimes) { runtime.walletSecret?.fill(0) diff --git a/src/stores/nwc.atomic.test.ts b/src/stores/nwc.atomic.test.ts index c2cc13a..62dbbb7 100644 --- a/src/stores/nwc.atomic.test.ts +++ b/src/stores/nwc.atomic.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { NwcService, NwcServiceDeps } from '@/lnurlcash/nwc'; import type * as NwcExports from '@/lnurlcash/nwc'; +import { FUNDS_STORAGE_KEY } from '@/lnurlcash/storage/bearers'; import { stubLocalStorage } from '@/lnurlcash/test-utils'; import type { NewBearer } from '@/lnurlcash/types'; import { useActivityStore } from './activity'; @@ -29,7 +30,11 @@ const note = (secret: string): NewBearer => ({ beforeEach(() => { vi.clearAllMocks(); vi.unstubAllGlobals(); - vi.stubGlobal('navigator', {}); + // a present-but-non-serializing LockManager fake: walletFunds serializes + // its own mutations, so single-store tests only need locks to EXIST + vi.stubGlobal('navigator', { + locks: { request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn) }, + }); stubLocalStorage(); setActivePinia(createPinia()); mocks.startService.mockResolvedValue({ @@ -69,7 +74,7 @@ describe('NWC store changeset adapter', () => { start[1].assertCurrentOwner, ); - expect(writes.mock.calls.filter(([key]) => key === 'sattle_bearers')).toHaveLength(1); + expect(writes.mock.calls.filter(([key]) => key === FUNDS_STORAGE_KEY)).toHaveLength(1); expect(wallet.bearers).toHaveLength(2); expect(wallet.bearers.find(({ id }) => id === existing.id)?.spent).toBe(true); }); diff --git a/src/stores/nwc.lifecycle.test.ts b/src/stores/nwc.lifecycle.test.ts index 34c6f3b..211a69f 100644 --- a/src/stores/nwc.lifecycle.test.ts +++ b/src/stores/nwc.lifecycle.test.ts @@ -16,16 +16,58 @@ import { import { setNwcTransportForTests, useNwcStore } from './nwc'; import { useWalletStore } from './wallet'; +// a present-but-non-serializing LockManager fake: walletFunds serializes its +// own mutations, so single-store tests only need locks to EXIST +const stubPassthroughLocks = (): void => { + vi.stubGlobal('navigator', { + locks: { request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn) }, + }); +}; + beforeEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); - vi.stubGlobal('navigator', {}); + stubPassthroughLocks(); stubLocalStorage(); setActivePinia(createPinia()); setNwcTransportForTests(null); }); describe('NWC wallet lifecycle drain', () => { + it('keeps one service when enabling twice', async () => { + const wallet = useWalletStore(); + await wallet.create(); + const relay = createFakeRelay(); + setNwcTransportForTests(relay.transport); + const nwc = useNwcStore(); + nwc.create(RELAYS, { maxMsat: 50_000, periodMs: 86_400_000 }); + + await nwc.setEnabled(true); + await nwc.setEnabled(true); + + expect(relay.subscriptionCount()).toBe(1); + await nwc.stop(); + expect(relay.subscriptionCount()).toBe(0); + }); + + it('drains an accepted foreground fund operation before lock clears its fence', async () => { + const wallet = useWalletStore(); + await wallet.create('password'); + const operation = wallet.beginFundOperation(); + let lockSettled = false; + + const locking = wallet.lock().then(() => { + lockSettled = true; + }); + await vi.waitFor(() => expect(() => wallet.captureOwnerFence()).toThrow()); + expect(lockSettled).toBe(false); + operation.ownerFence(); + + operation.complete(); + await locking; + expect(wallet.state).toBe('locked'); + }); + it('commits an accepted melted payment before ordinary lock invalidates its owner fence', async () => { // Given a real encrypted wallet and NWC service whose payment is held // after the mint burned its note but before the wallet commits the delta diff --git a/src/stores/nwc.ts b/src/stores/nwc.ts index 9fdc156..f3e4015 100644 --- a/src/stores/nwc.ts +++ b/src/stores/nwc.ts @@ -1,3 +1,4 @@ +// allow: SIZE_OK — indivisible NWC service lifecycle state machine and Pinia control surface. import { ref, watch } from 'vue'; import { defineStore } from 'pinia'; @@ -21,6 +22,7 @@ import { writeNwcEnabled, } from '@/lnurlcash/nwc'; import { linkingPubKeyHex } from '@/lnurlcash/keys'; +import { getTrustedMintVerificationKeys } from '@/lnurlcash/trustedMints'; import { msatToSats } from '@/lnurlcash/units'; import { TrustedMintPostCommitError, useWalletStore } from './wallet'; import { useMintsStore } from './mints'; @@ -142,11 +144,77 @@ export const useNwcStore = defineStore('nwc', () => { lastError.value = ''; try { const ownerFence = wallet.captureOwnerFence(); + if (wallet.bearers.some((bearer) => bearer.pendingMint && !bearer.spent)) { + await wallet.recoverPendingMints(ownerFence); + if (token !== startToken || wallet.state !== 'unlocked' || !enabled.value) return; + } const started = await startService(wallet.requireLinkingKey(), { // only spendable notes may back an NWC payment getBearers: () => wallet.unspentBearers, getDefaultMint: () => mints.defaultMint, assertCurrentOwner: ownerFence, + allocateOutputSecrets: (server, count, assertOwner) => + wallet.allocateOutputSecrets(server, count, assertOwner), + // a mid-request lockdown must not verify anything new: no keys + mintSignatureKeys: (server) => + wallet.state === 'unlocked' && wallet.pubkey !== null + ? getTrustedMintVerificationKeys(server, wallet.pubkey) + : [], + recoverPendingMints: (assertOwner) => wallet.recoverPendingMints(assertOwner), + persistMintOutput: async (note, assertOwner) => { + const [persisted] = await wallet.addBearers([note], assertOwner); + if (!persisted) throw new Error('The pending mint output was not persisted.'); + return persisted; + }, + discardMintOutput: async (staged, assertOwner) => { + await wallet.removeNote(staged.id, assertOwner); + }, + setMintOutputRetirement: async (staged, retireAfter, assertOwner) => + wallet.updateBearer( + staged.id, + { pendingMint: { ...staged.pendingMint, retireAfter } }, + assertOwner, + ), + reserveMintOutputSource: async (staged, sourceId, recoverySecret, assertOwner) => { + await wallet.reserveStagedMintSource(staged.id, sourceId, recoverySecret, assertOwner); + }, + finalizeMintOutput: async (staged, note, assertOwner) => { + try { + await wallet.finalizeStagedMintOutput(staged.id, note, assertOwner); + } catch (error) { + if (!(error instanceof TrustedMintPostCommitError)) throw error; + lastError.value = error.message; + } + await activity.log( + 'nwc', + `Received ${formatSats(note.amount)} sats via NWC.`, + (error) => { + lastError.value = error.message; + }, + ); + }, + finalizePaymentReturn: async (staged, note, assertOwner) => { + try { + await wallet.finalizeStagedMintOutput(staged.id, note, assertOwner); + } catch (error) { + if (!(error instanceof TrustedMintPostCommitError)) throw error; + lastError.value = error.message; + } + }, + finalizeSpentMintOutput: async (staged, assertOwner) => { + await wallet.finalizeSpentStagedMintOutput(staged.id, assertOwner); + }, + commitCarve: async (carve, assertOwner) => { + try { + return await wallet.commitCarve(carve, assertOwner); + } catch (error) { + if (!(error instanceof TrustedMintPostCommitError)) throw error; + lastError.value = error.message; + const committed = wallet.bearers.find((bearer) => bearer.url === carve.note.url); + if (!committed) throw error; + return committed; + } + }, applyChangeset, transport: transportOverride ?? undefined, onError: (error) => { @@ -193,7 +261,13 @@ export const useNwcStore = defineStore('nwc', () => { running.value = false; const priorStop = stopping; const activeStop = active?.stop() ?? Promise.resolve(); - const completion = Promise.all([priorStop, activeStop, ...pendingStarts]).then(() => undefined); + const completion = Promise.allSettled([priorStop, activeStop, ...pendingStarts]).then( + (results) => { + for (const result of results) { + if (result.status === 'rejected') throw result.reason; + } + }, + ); pendingStop = completion; stopping = completion.catch(() => undefined); return completion; @@ -232,6 +306,10 @@ export const useNwcStore = defineStore('nwc', () => { // ---- settings ---- const setEnabled = async (value: boolean): Promise => { + if (value === enabled.value) { + if (value && !running.value && pendingStarts.size === 0) await start(); + return; + } const ownerId = ownerFromWallet(); writeNwcEnabled(ownerId, value); enabled.value = value; diff --git a/src/stores/wallet.ts b/src/stores/wallet.ts index f31106d..1899452 100644 --- a/src/stores/wallet.ts +++ b/src/stores/wallet.ts @@ -67,6 +67,7 @@ export const useWalletStore = defineStore('wallet', () => { let currentMaterial: WalletMaterialV2 | null = null; let lifecycleToken = 0; let acceptingOwnerWork = false; + const foregroundFundOperations = new Set>(); const lockWarningSecondsLeft = ref(null); const runTransition = createWalletTransitionQueue({ @@ -105,12 +106,22 @@ export const useWalletStore = defineStore('wallet', () => { pubkey.value = null; }; + const drainAcceptedOwnerWork = async (): Promise => { + const results = await Promise.allSettled([ + stopWalletNwcSession(), + ...foregroundFundOperations, + ]); + for (const result of results) { + if (result.status === 'rejected') throw result.reason; + } + }; + const deactivateSession = async (): Promise => { acceptingOwnerWork = false; stopOwnerChanges(); idleWatch.stop(); try { - await stopWalletNwcSession(); + await drainAcceptedOwnerWork(); } finally { // even a rejected drain ends the session: 'locked' never holds key // material and no captured fence stays valid @@ -201,7 +212,7 @@ export const useWalletStore = defineStore('wallet', () => { // the drain runs before the fence is invalidated and the runtime is // cleared so an in-flight fund-critical changeset can still commit // (its applyChangeset needs the live fence and key) - await stopWalletNwcSession(); + await drainAcceptedOwnerWork(); invalidateLifecycle(); clearRuntime(); if (ownerId === null) await clearUnownedAuthorizations(); @@ -323,6 +334,28 @@ export const useWalletStore = defineStore('wallet', () => { return currentMaterial; }; + const beginFundOperation = (): { + readonly ownerFence: () => void; + readonly complete: () => void; + } => { + const captured = ownerFence.capture(); + let completePromise: (() => void) | undefined; + const completion = new Promise((resolve) => { + completePromise = resolve; + }); + foregroundFundOperations.add(completion); + let active = true; + return { + ownerFence: captured, + complete: () => { + if (!active) return; + active = false; + foregroundFundOperations.delete(completion); + completePromise?.(); + }, + }; + }; + const funds = createWalletFunds({ requireKey, requireCashRoot: () => cashNodeFromHex(requireWalletMaterial().cashRootHex), @@ -355,5 +388,6 @@ export const useWalletStore = defineStore('wallet', () => { requireLinkingKey, requireWalletMaterial, captureOwnerFence: ownerFence.capture, + beginFundOperation, }; });