fix(nwc): await durable commits before responses

This commit is contained in:
2026-09-05 11:38:29 +02:00
parent 22b1c5595c
commit 29830dc9e3
14 changed files with 700 additions and 281 deletions
+70 -120
View File
@@ -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<Omit<PollOptions, 'signal'>> = {
maxWaitMs: 15 * 60_000,
}
// LUD-21 verify URLs end in /verify/<payment_hash> (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<string, unknown> => {
const result: Record<string, unknown> = {
type: 'incoming',
@@ -56,89 +84,18 @@ export const invoiceResult = (entry: PendingInvoice): Record<string, unknown> =>
}
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<void> => {
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<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)
// 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
}