fix: hide invoice settlement until commit

This commit is contained in:
2026-08-22 16:55:35 +02:00
parent 09b7b63c9d
commit 513c67fc14
3 changed files with 474 additions and 27 deletions
+206
View File
@@ -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)
})
})
+241
View File
@@ -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()
})
})
+27 -27
View File
@@ -23,10 +23,10 @@ import type {PollOptions} from '../ops/shared'
import type {PendingInvoice, RequestContext} from './context'
export const DEFAULT_CLAIM_POLL: Required<PollOptions> = {
export const DEFAULT_CLAIM_POLL: Required<Omit<PollOptions, 'signal'>> = {
intervalMs: 2000,
intervalCapMs: 10_000,
maxWaitMs: 15 * 60_000
maxWaitMs: 15 * 60_000,
}
// LUD-21 verify URLs end in /verify/<payment_hash> (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<string, unknown> => {
export const invoiceResult = (entry: PendingInvoice): Record<string, unknown> => {
const result: Record<string, unknown> = {
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<void> => {
export const settleAndClaim = async (ctx: RequestContext, entry: PendingInvoice): Promise<void> => {
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')
}