feat(nwc): make_invoice against mints with wallet-named outputs

The named-mint branch claims by the wallet's secret and treats the verify
preimage as an ordinary payment receipt - but keeps the rotated guard
before handing it out, because the rescue path (a mint that credited the
preimage despite the binding) claims through claimFromPreimage, where a
failed rotate leaves that preimage a live note secret. possibleCopy is
committed exactly like the unnamed path.
This commit is contained in:
2026-08-31 12:23:10 +02:00
parent c9963934aa
commit ae5064b69a
2 changed files with 199 additions and 14 deletions
+124 -1
View File
@@ -11,7 +11,7 @@ import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure' import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
import {v2 as nip44v2} from 'nostr-tools/nip44' import {v2 as nip44v2} from 'nostr-tools/nip44'
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit' import {buildNoteUrl, fetchNoteInfo, hashK1, noteK1} from 'lnurlcash-kit'
import {createMockMint} from 'lnurlcash-conformance/mock-mint' import {createMockMint} from 'lnurlcash-conformance/mock-mint'
import { import {
@@ -124,6 +124,129 @@ describe('service: make_invoice / lookup_invoice', () => {
await stop() 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 () => { it('keeps a paid invoice pending while its bearer commit is deferred', async () => {
const m = await mint({testHooks: true}) const m = await mint({testHooks: true})
const commit = deferred() const commit = deferred()
+75 -13
View File
@@ -1,15 +1,18 @@
// The invoice registry the two-phase make_invoice / lookup_invoice pair // The invoice registry the two-phase make_invoice / lookup_invoice pair
// shares: make_invoice answers with the invoice immediately while this // shares: make_invoice answers with the invoice immediately while this
// module's settleAndClaim watches settlement in the background, claims // module's settleAndClaim watches settlement in the background, claims
// the minted note (rotating it immediately), and only THEN records the // the minted note, and only THEN records the settlement the lookup
// settlement the lookup reports. // reports.
// //
// THE PREIMAGE, in lnurlcash terms: the payment preimage IS the minted // THE PREIMAGE, in lnurlcash terms: on an UNNAMED mint the payment
// note's initial secret. NIP-47 clients expect it as the settlement // preimage IS the minted note's initial secret. NIP-47 clients expect it
// receipt, so it is handed out - but only after claimFromPreimage has // as the settlement receipt, so it is handed out - but only after
// rotated the fresh note, when that secret is burned and worthless. If // claimFromPreimage has rotated the fresh note, when that secret is
// the rotation failed (claimed.rotated === false) the preimage is // burned and worthless. If the rotation failed (claimed.rotated ===
// withheld: fund safety over spec comfort. // 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.
import {isPreimage, sameInvoice} from 'lnurlcash-kit' import {isPreimage, sameInvoice} from 'lnurlcash-kit'
import {sha256} from '@noble/hashes/sha2.js' import {sha256} from '@noble/hashes/sha2.js'
@@ -17,7 +20,7 @@ import {bytesToHex, utf8ToBytes} from '@noble/hashes/utils.js'
import type {NewBearer} from '../types' import type {NewBearer} from '../types'
import type {PreparedMint} from '../ops' import type {PreparedMint} from '../ops'
import {claimFromPreimage} from '../ops/mint' import {claimFromPreimage, claimFromSecret} from '../ops/mint'
import {pollVerifyUntilSettled} from '../ops/shared' import {pollVerifyUntilSettled} from '../ops/shared'
import type {PollOptions} from '../ops/shared' import type {PollOptions} from '../ops/shared'
@@ -61,13 +64,14 @@ export const invoiceResult = (entry: PendingInvoice): Record<string, unknown> =>
} }
// the background half of make_invoice: watch the invoice, and once it // the background half of make_invoice: watch the invoice, and once it
// settles claim the note (rotating it immediately) and hand the fresh // settles claim the note (rotating it immediately on an unnamed mint) and
// bearer to the caller. Settlement is recorded LAST - after the rotate and // hand the fresh bearer to the caller. Settlement is recorded LAST - after
// bearer commit - so lookup can only reveal durably tracked funds and an // the claim and bearer commit - so lookup can only reveal durably tracked
// already-burned secret. // funds and, on an unnamed mint, an already-burned secret.
// Throws on any failure; the caller marks the entry failed and reports // Throws on any failure; the caller marks the entry failed and reports
// through deps.onError. // 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.mode === 'named') return settleAndClaimNamed(ctx, entry)
if (!entry.prepared.verifyUrl) { if (!entry.prepared.verifyUrl) {
throw new Error( 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.',
@@ -114,3 +118,61 @@ export const settleAndClaim = async (ctx: RequestContext, entry: PendingInvoice)
entry.preimage = preimage 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<void> => {
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)
const result = await pollVerifyUntilSettled(
prepared.verifyUrl,
{...claimPoll, signal: ctx.stopSignal},
ctx.deps.kit ?? {},
)
if (!sameInvoice(result.pr, prepared.invoice)) {
throw new Error("The service's verify response is for a different invoice than requested.")
}
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,
)
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
}