mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
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:
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user