From bb147faef1127ac3541c715e64fb003e0d2d912f Mon Sep 17 00:00:00 2001 From: protom Date: Mon, 31 Aug 2026 12:22:21 +0200 Subject: [PATCH 01/12] build(deps): bump lnurlcash-kit to 0.5.0 and lnurlcash-conformance to 0.4.0 The kit release line adds the LUD-25 output-naming protocol (comment/h on the pay callback, claim polling, merge batching); the mock mint now defaults to plain https withdraw links and serves the originally issued invoice on verify, which the two adapted tests pinned the old shape of. --- package-lock.json | 16 ++++++------ package.json | 4 +-- src/lnurlcash/mintContract.test.ts | 5 ++-- src/lnurlcash/ops.mint-receive.cases.ts | 33 +++++++++++++++++++------ 4 files changed, 38 insertions(+), 20 deletions(-) diff --git a/package-lock.json b/package-lock.json index e859c3b..3e53753 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@quasar/extras": "^2.0.4", "@scure/bip32": "^2.3.0", "@scure/bip39": "^2.3.0", - "lnurlcash-kit": "0.1.1", + "lnurlcash-kit": "0.5.0", "nostr-tools": "2.24.3", "pinia": "^4.0.2", "qrcode.vue": "^3.10.0", @@ -46,7 +46,7 @@ "eslint": "^10.8.0", "eslint-plugin-vue": "^10.8.0", "globals": "^17.4.0", - "lnurlcash-conformance": "0.1.1", + "lnurlcash-conformance": "0.4.0", "postcss": "^8.5.8", "prettier": "^3.8.1", "sass": "^1.102.0", @@ -5961,9 +5961,9 @@ } }, "node_modules/lnurlcash-conformance": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/lnurlcash-conformance/-/lnurlcash-conformance-0.1.1.tgz", - "integrity": "sha512-12EXkom7x+tpkk4Pi2zanlMbDpA64hh+b5PKTPoKfahiWPG3GQbNusun1NHKpYjNwyevRISTP9487W+ZqEuEZw==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/lnurlcash-conformance/-/lnurlcash-conformance-0.4.0.tgz", + "integrity": "sha512-PsmjMrle5DE+OeTxLdBRD6ah63m9XPpNlPvB9f4FUdCtI6FbW6k5HcinMMt0mTkd9X9iOZHKG9SIqZ5HQJ0BDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5987,9 +5987,9 @@ } }, "node_modules/lnurlcash-kit": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/lnurlcash-kit/-/lnurlcash-kit-0.1.1.tgz", - "integrity": "sha512-O1rak4hcoKIBZdtbCMfQhCS4hp9HducJeJf8nEYxbPXtFm83EyPBqOAwWHYkzJMFKnyTqjJmvjjzfgL3ePQQOw==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/lnurlcash-kit/-/lnurlcash-kit-0.5.0.tgz", + "integrity": "sha512-ASX+/yduZjV9aLSoUXlP06dWNJ6Bt3IYrG0QoAqtvnC1h8lCs5rWVzw9RgmE0j1sZs16V2RicDlaSvON4JV8Pg==", "license": "MIT", "dependencies": { "@noble/curves": "^2.3.0", diff --git a/package.json b/package.json index 5a68016..e5fb4d1 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@quasar/extras": "^2.0.4", "@scure/bip32": "^2.3.0", "@scure/bip39": "^2.3.0", - "lnurlcash-kit": "0.1.1", + "lnurlcash-kit": "0.5.0", "nostr-tools": "2.24.3", "pinia": "^4.0.2", "qrcode.vue": "^3.10.0", @@ -57,7 +57,7 @@ "eslint": "^10.8.0", "eslint-plugin-vue": "^10.8.0", "globals": "^17.4.0", - "lnurlcash-conformance": "0.1.1", + "lnurlcash-conformance": "0.4.0", "postcss": "^8.5.8", "prettier": "^3.8.1", "sass": "^1.102.0", diff --git a/src/lnurlcash/mintContract.test.ts b/src/lnurlcash/mintContract.test.ts index fd7c4aa..e9f6c3a 100644 --- a/src/lnurlcash/mintContract.test.ts +++ b/src/lnurlcash/mintContract.test.ts @@ -116,9 +116,10 @@ describe('mint-address wire contract', () => { describe('withdraw-link forms', () => { it('accepts the lnurlw:// withdraw link the conformance mock mint advertises', async () => { - const m = await mint({testHooks: true}) + // conformance 0.4.0 defaults to plain https links; lnurlw:// (the form + // lnurl-mint emits under LUD-17) is an explicit option + const m = await mint({testHooks: true, withdrawLinkForm: 'lnurlw'}) const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000) - // published conformance 0.1.1 emits lnurlw:// by default - NOT https expect(prepared.withdrawLink).toMatch(/^lnurlw:\/\//) // and the link is fully usable: settle the invoice, claim the note diff --git a/src/lnurlcash/ops.mint-receive.cases.ts b/src/lnurlcash/ops.mint-receive.cases.ts index 9649448..3989d7c 100644 --- a/src/lnurlcash/ops.mint-receive.cases.ts +++ b/src/lnurlcash/ops.mint-receive.cases.ts @@ -2,7 +2,6 @@ import {describe, expect, it} from 'vitest' import { NoteSpentError, PendingNoteError, - buildNoteUrl, fetchNoteInfo, meltNote, noteK1, @@ -39,14 +38,32 @@ describe('mint -> claim -> rotate', () => { const instance = await mint({testHooks: true, baseFeeMsat: 1_000, feePpm: 2_000}) const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 100_000) expect(prepared.grossMsat).toBeGreaterThan(100_000) - const preimage = await settleLastInvoice(instance) - const info = await fetchNoteInfo( - buildNoteUrl(prepared.withdrawLink, preimage, prepared.expectedNoteValueMsat), - ) - expect(info.maxWithdrawable).toBeGreaterThanOrEqual(99_000) - expect(info.maxWithdrawable).toBeLessThanOrEqual(prepared.grossMsat) + await settleLastInvoice(instance) + const claimed = await claimMintedNote(prepared, { + intervalMs: 10, + intervalCapMs: 20, + maxWaitMs: 5_000, + }) + // the service's maxWithdrawable is authoritative: the fee is withheld + // from the gross, so the note nets between the asked value and the gross + expect(claimed.note.amount).toBeGreaterThanOrEqual(99_000) + expect(claimed.note.amount).toBeLessThanOrEqual(prepared.grossMsat) + expect(claimed.rotated).toBe(true) + }) + + it('refuses a verify response naming a different invoice', async () => { + const instance = await mint({testHooks: true}) + const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) + await settleLastInvoice(instance) + // a verify answer that names any invoice but the requested one must + // stop the claim - the preimage it carries belongs to another payment + const other = `${prepared.invoice.slice(0, -1)}${prepared.invoice.endsWith('q') ? 'p' : 'q'}` await expect( - claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 500}), + claimMintedNote({...prepared, invoice: other}, { + intervalMs: 10, + intervalCapMs: 20, + maxWaitMs: 5_000, + }), ).rejects.toThrow(/different invoice/) }) From 9f4ae3b0f5b6ff59515c5936ab8b9c08a4b51d9e Mon Sep 17 00:00:00 2001 From: protom Date: Mon, 31 Aug 2026 12:22:30 +0200 Subject: [PATCH 02/12] refactor(lnurlcash): generalize bounded polling beyond verify endpoints pollVerifyUntilSettled keeps its exact behavior and messages, reimplemented on a generic pollUntil so flows that watch something other than a LUD-21 verify (a named mint output appearing) share the same backoff, budget and abort semantics. --- src/lnurlcash/ops/shared.ts | 45 +++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/src/lnurlcash/ops/shared.ts b/src/lnurlcash/ops/shared.ts index b93fade..d20a70f 100644 --- a/src/lnurlcash/ops/shared.ts +++ b/src/lnurlcash/ops/shared.ts @@ -76,16 +76,17 @@ const abortableSleep = (ms: number, signal: AbortSignal): Promise => signal.addEventListener('abort', onAbort, {once: true}) }) -// polls a LUD-21/LUD-25 verify endpoint until it reports settled, with -// backoff, inside a total time budget. A single failed check isn't fatal - -// the next round tries again. Returns the settled VerifyResult; throws on -// budget exhaustion, or PollAbortedError when the caller's signal fires -// (a hung fetch is interrupted too: the signal is bound into the request). -export const pollVerifyUntilSettled = async ( - verifyUrl: string, +// polls `check` until it yields a value, with backoff, inside a total +// time budget. A single failed check isn't fatal - the next round tries +// again. Returns the first value; throws on budget exhaustion, or +// PollAbortedError when the caller's signal fires (a hung fetch is +// interrupted too: the signal is bound into the request). +export const pollUntil = async ( + check: (options: LnurlcashOptions) => Promise, + exhausted: string, poll: PollOptions, options: LnurlcashOptions, -): Promise => { +): Promise => { const {intervalMs, intervalCapMs, maxWaitMs, signal} = { ...DEFAULT_POLL, ...poll, @@ -105,8 +106,8 @@ export const pollVerifyUntilSettled = async ( while (Date.now() < deadline) { if (signal?.aborted) throw new PollAbortedError() try { - const result = await fetchInvoiceVerification(verifyUrl, fetchOptions) - if (result.settled) return result + const result = await check(fetchOptions) + if (result !== null) return result lastError = null } catch (err) { // the signal's own AbortError lands here on an interrupted fetch @@ -118,7 +119,27 @@ export const pollVerifyUntilSettled = async ( delay = Math.min(delay * 2, intervalCapMs) } if (lastError instanceof Error) { - throw new Error(`Payment not confirmed: ${lastError.message}`) + throw new Error(`${exhausted}: ${lastError.message}`) } - throw new Error('Payment not confirmed within the time budget.') + throw new Error(`${exhausted} within the time budget.`) } + +// polls a LUD-21/LUD-25 verify endpoint until it reports settled, with +// backoff, inside a total time budget. A single failed check isn't fatal - +// the next round tries again. Returns the settled VerifyResult; throws on +// budget exhaustion, or PollAbortedError when the caller's signal fires +// (a hung fetch is interrupted too: the signal is bound into the request). +export const pollVerifyUntilSettled = ( + verifyUrl: string, + poll: PollOptions, + options: LnurlcashOptions, +): Promise => + pollUntil( + async (fetchOptions) => { + const result = await fetchInvoiceVerification(verifyUrl, fetchOptions) + return result.settled ? result : null + }, + 'Payment not confirmed', + poll, + options, + ) From 58268b2921d05c29ed4df98891e5ab5069617dfb Mon Sep 17 00:00:00 2001 From: protom Date: Mon, 31 Aug 2026 12:22:45 +0200 Subject: [PATCH 03/12] feat(lnurlcash): claim mint outputs named by a wallet-chosen secret lnurl-mint >= 0.4 refuses a mint quote without comment=sha256(secret) and keys the note by that secret instead of the payment preimage. prepareMint detects the capability per mint (commentAllowed >= 64 or mintToHash), binds the quote to a wallet-drawn secret, and claimMintedNote polls the note itself - no preimage, no rotate, since the secret never rode an invoice. Mints that never advertised the capability get the byte-identical old request and stay on the preimage path; a mint that took the hash but credited the preimage is caught by the claim poll and rescued through the quote's verify URL. The d.ts augments the conformance package's stale published declarations for its comment-naming flags. --- src/lnurlcash/conformance-mockmint.d.ts | 30 ++++ src/lnurlcash/mintContract.test.ts | 103 ++++++++++++- src/lnurlcash/ops.mint-named.cases.ts | 121 +++++++++++++++ src/lnurlcash/ops.test.ts | 1 + src/lnurlcash/ops/mint.ts | 191 ++++++++++++++++++++---- 5 files changed, 412 insertions(+), 34 deletions(-) create mode 100644 src/lnurlcash/conformance-mockmint.d.ts create mode 100644 src/lnurlcash/ops.mint-named.cases.ts diff --git a/src/lnurlcash/conformance-mockmint.d.ts b/src/lnurlcash/conformance-mockmint.d.ts new file mode 100644 index 0000000..0637d1f --- /dev/null +++ b/src/lnurlcash/conformance-mockmint.d.ts @@ -0,0 +1,30 @@ +// The published lnurlcash-conformance 0.4.0 hand-written declarations +// (mock-mint/index.d.ts) are stale relative to their own index.mjs: the +// comment-naming flags are implemented and documented in the CHANGELOG +// ("The mock mint gains commentAllowed, commentRefusesMalformed and +// verifyOnUnnamedMint") but never made it into the type surface. This +// augmentation restores them so tests can drive the comment-named mint +// mode without `as any`. Report upstream; drop this file once a release +// ships the declarations. +// +// Semantics (from mock-mint/index.mjs): +// - commentAllowed: false (default) means the pay callback ignores the +// `comment` parameter entirely. A number advertises that many +// characters on the payRequest and reads `comment` on the callback: a +// bare 64-hex sha256 binds the minted note to the wallet's secret +// (k1 = secret, preimage opens nothing); anything else falls back to +// preimage keying and verify is withheld for that quote. +// - commentRefusesMalformed: non-compliant; refuses a non-hash comment +// instead of falling back. +// - verifyOnUnnamedMint: non-compliant; serves LUD-21 verify even on the +// no-comment fallback, where the preimage it hands out IS the note. + +declare module 'lnurlcash-conformance/mock-mint' { + interface MockMintOptions { + commentAllowed?: number | false + commentRefusesMalformed?: boolean + verifyOnUnnamedMint?: boolean + } +} + +export {} diff --git a/src/lnurlcash/mintContract.test.ts b/src/lnurlcash/mintContract.test.ts index e9f6c3a..4643fb6 100644 --- a/src/lnurlcash/mintContract.test.ts +++ b/src/lnurlcash/mintContract.test.ts @@ -1,15 +1,21 @@ -// Wire-contract coverage for the pinned lnurlcash-kit / lnurlcash-conformance -// 0.1.1 artifacts. Two contracts the app's mint discovery relies on: +// Wire-contract coverage for the pinned lnurlcash-kit 0.5.0 / +// lnurlcash-conformance 0.4.0 artifacts. Contracts the app's mint flows +// rely on: // the kit must MAP the mint-address wire field `nodeCapacity` onto the -// app-facing `nodeCapacityMsat` (0.1.0 spread it under its wire name, so the -// typed field read undefined forever), and a payRequest withdraw link is -// legal in both its HTTPS and LUD-17 `lnurlw://` forms - the published -// conformance mock mint emits `lnurlw://` by default, so do NOT assume an -// HTTPS default anywhere in the receive path. +// app-facing `nodeCapacityMsat` (0.1.0 spread it under its wire name, so +// the typed field read undefined forever); a payRequest withdraw link is +// legal in both its HTTPS and LUD-17 `lnurlw://` forms (the conformance +// mock emits either, selected by its withdrawLinkForm option, so do NOT +// assume a default anywhere in the receive path); and the LUD-06 quote +// request must carry the output-naming hash (as BOTH `comment` and `h`) +// exactly when - and only when - the payRequest advertised the capability +// (commentAllowed >= 64 or mintToHash): a new-generation mint +// (lnurl-mint >= 0.4) refuses a quote without it, while an old mint must +// see the byte-identical request this wallet always sent. import {afterEach, describe, expect, it} from 'vitest' import {createMockMint} from 'lnurlcash-conformance/mock-mint' -import {buildNoteUrl, fetchMintAddress} from 'lnurlcash-kit' +import {buildNoteUrl, fetchMintAddress, hashK1} from 'lnurlcash-kit' import {claimMintedNote, prepareMint} from './ops' import {mintAddressCacheInfo} from './trustedMints' @@ -77,6 +83,20 @@ const fixtureFetch = (routes: ReadonlyArray): typeof return impl } +// fixtureFetch plus a record of every URL it saw, for canaries about the +// exact query the kit puts on the wire +const capturingFetch = ( + routes: ReadonlyArray, + seen: string[], +): typeof fetch => { + const impl: typeof fetch = (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + seen.push(url) + return fixtureFetch(routes)(input, init) + } + return impl +} + describe('mint-address wire contract', () => { it('maps the wire nodeCapacity onto the app-facing nodeCapacityMsat', async () => { const info = await fetchMintAddress('https://mint.example/.well-known/lnurlw/mint', { @@ -164,3 +184,70 @@ describe('withdraw-link forms', () => { ) }) }) + +describe('quote output-naming wire contract', () => { + const payRequestFixture = (extra: Record) => ({ + tag: 'payRequest', + callback: 'https://mint.example/pay', + minSendable: 1_000, + maxSendable: 100_000_000_000, + withdrawLink: 'https://mint.example/note', + metadata: '[]', + ...extra, + }) + const routesFor = (payRequest: Record) => + [ + ['https://mint.example/.well-known/lnurlw/mint', mintAddressFixture], + ['https://mint.example/.well-known/lnurlp/mint', payRequest], + // amount-less invoice: the kit skips its amount cross-check + ['https://mint.example/pay', {pr: 'lnmock1fixture', verify: null}], + ] as const + + const callbackUrl = (seen: string[]): URL => { + const hit = seen.find((url) => url.startsWith('https://mint.example/pay?')) + if (!hit) throw new Error('the quote request never hit the wire') + return new URL(hit) + } + + it('sends no comment/h to a mint that never advertised output naming', async () => { + const seen: string[] = [] + const prepared = await prepareMint('mint@mint.example', 21_000, { + fetch: capturingFetch(routesFor(payRequestFixture({})), seen), + }) + expect(prepared.mode).toBe('unnamed') + expect(prepared.noteSecret).toBeUndefined() + // an old mint sees the byte-identical request this wallet always sent + const url = callbackUrl(seen) + expect(url.searchParams.get('comment')).toBeNull() + expect(url.searchParams.get('h')).toBeNull() + }) + + it('sends comment AND h, both sha256 of the wallet secret, when commentAllowed >= 64', async () => { + const seen: string[] = [] + const prepared = await prepareMint('mint@mint.example', 21_000, { + fetch: capturingFetch(routesFor(payRequestFixture({commentAllowed: 64})), seen), + }) + expect(prepared.mode).toBe('named') + const noteSecret = prepared.noteSecret + if (!noteSecret) throw new Error('named mode without a note secret') + const url = callbackUrl(seen) + // both spellings go out together: `comment` is the LUD-25 form + // lnurl-mint >= 0.4 REQUIRES, `h` the earlier form other mints read + const comment = url.searchParams.get('comment') + const h = url.searchParams.get('h') + expect(comment).toMatch(/^[0-9a-f]{64}$/) + expect(h).toBe(comment) + expect(comment).toBe(hashK1(noteSecret)) + }) + + it('names the output when only the mintToHash spelling is advertised', async () => { + const seen: string[] = [] + const prepared = await prepareMint('mint@mint.example', 21_000, { + fetch: capturingFetch(routesFor(payRequestFixture({mintToHash: true})), seen), + }) + expect(prepared.mode).toBe('named') + const noteSecret = prepared.noteSecret + if (!noteSecret) throw new Error('named mode without a note secret') + expect(callbackUrl(seen).searchParams.get('h')).toBe(hashK1(noteSecret)) + }) +}) diff --git a/src/lnurlcash/ops.mint-named.cases.ts b/src/lnurlcash/ops.mint-named.cases.ts new file mode 100644 index 0000000..5e2d3de --- /dev/null +++ b/src/lnurlcash/ops.mint-named.cases.ts @@ -0,0 +1,121 @@ +import {describe, expect, it} from 'vitest' +import {hashK1, noteK1} from 'lnurlcash-kit' + +import {claimMintedNote, prepareMint} from './ops' +import {requiredValue} from './test-utils' +import type {Mint} from './ops.testHarness' +import {mint, settleLastInvoice} from './ops.testHarness' + +const HEX32 = /^[0-9a-f]{64}$/ + +// the invoice whose quote the wallet bound to its own output hash +const boundInvoice = (instance: Mint) => + [...instance.state.invoices.values()].find((invoice) => invoice.boundTo) + +describe('mint -> claim, named output (comment/mintToHash)', () => { + it('names the quote with the hash of a wallet-chosen secret', async () => { + const instance = await mint({commentAllowed: 64, testHooks: true}) + const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) + expect(prepared.mode).toBe('named') + const noteSecret = requiredValue(prepared.noteSecret) + expect(noteSecret).toMatch(HEX32) + // the mint bound the quote to sha256 of the wallet's secret - the kit + // sends it as both comment and h, the mock read one of them + expect(boundInvoice(instance)?.boundTo).toBe(hashK1(noteSecret)) + }) + + it('claims a named output at the wallet secret, without a rotate', async () => { + const instance = await mint({commentAllowed: 64, testHooks: true}) + const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) + const noteSecret = requiredValue(prepared.noteSecret) + const preimage = await settleLastInvoice(instance) + const claimed = await claimMintedNote(prepared, { + intervalMs: 10, + intervalCapMs: 50, + maxWaitMs: 5_000, + }) + expect(claimed.rotated).toBe(true) + expect(claimed.note.verified).toBe(true) + expect(claimed.note.amount).toBe(21_000) + expect(claimed.note.callback).toBe(`${instance.url}/w/cb`) + expect(noteK1(claimed.note.url)).toBe(noteSecret) + // no rotate happened: the note still stands at the wallet's own + // secret, and the payment preimage keys nothing at all + expect(instance.state.noteState(noteSecret)).toBe('outstanding') + expect(instance.state.noteState(preimage)).toBeNull() + }) + + it('names the output through the mintToHash spelling too', async () => { + const instance = await mint({mintToHash: true, testHooks: true}) + const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) + expect(prepared.mode).toBe('named') + expect(boundInvoice(instance)?.boundTo).toBe(hashK1(requiredValue(prepared.noteSecret))) + const preimage = await settleLastInvoice(instance) + const claimed = await claimMintedNote(prepared, { + intervalMs: 10, + intervalCapMs: 50, + maxWaitMs: 5_000, + }) + expect(noteK1(claimed.note.url)).toBe(prepared.noteSecret) + expect(instance.state.noteState(preimage)).toBeNull() + }) + + it('keeps an unnamed mint on the preimage path', async () => { + const instance = await mint({testHooks: true}) + const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) + expect(prepared.mode).toBe('unnamed') + expect(prepared.noteSecret).toBeUndefined() + // the quote was NOT bound - nothing named an output + expect(boundInvoice(instance)).toBeUndefined() + }) + + it('times out cleanly when a named mint invoice is never paid', async () => { + const instance = await mint({commentAllowed: 64, testHooks: true}) + const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) + await expect( + claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 100}), + ).rejects.toThrow(/not confirmed/i) + }) + + it('rescues through the preimage when the mint credited it anyway and serves verify', async () => { + // the mintToHashIgnoresH adversary: claims the capability, binds + // nothing, credits the payment hash - the preimage is still the money + const instance = await mint({ + commentAllowed: 64, + mintToHashIgnoresH: true, + verifyOnUnnamedMint: true, + testHooks: true, + }) + const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) + const preimage = await settleLastInvoice(instance) + expect(instance.state.noteState(preimage)).toBe('outstanding') + const claimed = await claimMintedNote(prepared, { + intervalMs: 10, + intervalCapMs: 20, + maxWaitMs: 300, + }) + expect(claimed.rotated).toBe(true) + expect(claimed.note.verified).toBe(true) + // claimed through the preimage path and rotated off it, because that + // preimage rode the invoice + expect(instance.state.noteState(preimage)).toBe('burned') + const k1 = requiredValue(noteK1(claimed.note.url)) + expect(k1).not.toBe(preimage) + expect(instance.state.noteState(k1)).toBe('outstanding') + }) + + it('fails loudly when the mint credits the preimage and serves no verify', async () => { + const instance = await mint({commentAllowed: 64, mintToHashIgnoresH: true, testHooks: true}) + const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) + // verify is withheld on the unnamed fallback - there the preimage IS + // the note, so a compliant mint never serves it + expect(prepared.verifyUrl).toBeNull() + const preimage = await settleLastInvoice(instance) + await expect( + claimMintedNote(prepared, {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 200}), + ).rejects.toThrow(/not confirmed/i) + // the money exists at the preimage but is unreachable without verify - + // the wallet must NOT report a success here + expect(instance.state.noteState(preimage)).toBe('outstanding') + }) +}) diff --git a/src/lnurlcash/ops.test.ts b/src/lnurlcash/ops.test.ts index e7f160f..1311cb4 100644 --- a/src/lnurlcash/ops.test.ts +++ b/src/lnurlcash/ops.test.ts @@ -1,4 +1,5 @@ import './ops.carve.cases' +import './ops.mint-named.cases' import './ops.mint-receive.cases' import './ops.pay.cases' import './ops.transfer.cases' diff --git a/src/lnurlcash/ops/mint.ts b/src/lnurlcash/ops/mint.ts index 82f558c..ad04023 100644 --- a/src/lnurlcash/ops/mint.ts +++ b/src/lnurlcash/ops/mint.ts @@ -1,18 +1,39 @@ // Minting: receiving over Lightning. prepareMint resolves a mint and // requests the invoice; paying it brings the note into existence; -// claimMintedNote watches the payment and converts the revealed preimage -// into a rotated, wallet-owned bearer note. +// claimMintedNote watches the payment and converts it into a wallet-owned +// bearer note. +// +// Two mint generations exist on the wire, and this module speaks both: +// +// - UNNAMED (old): the minted note's k1 IS the payment preimage. The +// claim takes the preimage from the LUD-21 verify answer and rotates +// immediately - every routing node and anyone who saw the invoice knew +// that secret. +// - NAMED (LUD-25 comment/mintToHash, lnurl-mint >= 0.4 requires it): the +// wallet names the note at quote time with comment = sha256(secret) of +// a secret it chose, and the note lands at THAT secret. The preimage +// opens nothing, so no observer race exists and no rotate follows the +// claim; the claim poll itself is the placement proof (see +// claimFromSecret). +// +// The mode is decided per mint from the payRequest's capability +// advertisement (commentAllowed >= 64 or mintToHash), never assumed. import { AmbiguousMutationError, buildNoteUrl, + claimMintedNote as fetchMintClaim, + defaultRandomSecret, + fetchInvoiceVerification, fetchMintAddress, fetchNoteInfo, fetchPayRequest, grossUpForMintFee, + hashK1, isPreimage, lightningAddressUsername, mintAddressUrl, + namesMintOutput, probeBurnedNote, requestInvoice, resolveMintInput, @@ -21,16 +42,26 @@ import { serverOf, withNewK1, } from 'lnurlcash-kit' -import type {MintAddressInfo} from 'lnurlcash-kit' +import type {MintAddressInfo, MintClaim} from 'lnurlcash-kit' import type {NewBearer} from '../types' import {ceilMsatToSat} from '../units' import type {PollOptions} from './shared' import type {FundOperationOptions} from './shared' -import {assertFundOwner, pollVerifyUntilSettled} from './shared' +import {PollAbortedError, assertFundOwner, pollUntil, pollVerifyUntilSettled} from './shared' export type PreparedMint = { invoice: string verifyUrl: string | null + // how this mint keys the note the invoice pays for: 'named' mints credit + // a wallet-chosen secret (LUD-25 comment/mintToHash), 'unnamed' mints + // credit the payment preimage + mode: 'named' | 'unnamed' + // named mode only: the wallet-chosen secret the minted note lands at. + // Drawn before the invoice is requested (its hash binds the quote) and + // carried in memory only - never persisted, never logged. Losing it + // after payment loses the note, but nothing here was ever recoverable + // after a crash: the invoice and verify URL die in the same memory + noteSecret?: string // the net note value asked for - the claim cross-checks the service's // authoritative maxWithdrawable against it expectedNoteValueMsat: number @@ -85,10 +116,25 @@ export const prepareMint = async ( if (grossMsat < info.minSendable || grossMsat > info.maxSendable) { throw new Error("Amount is outside this mint's sendable range.") } - const invoice = await requestInvoice(info.callback, grossMsat, options) + // a mint that names its outputs takes comment = sha256(secret) with the + // quote; one that doesn't gets the byte-identical request this wallet + // always sent, and its note stays keyed by the payment preimage + const named = namesMintOutput(info) + let noteSecret: string | undefined + let h: string | undefined + if (named) { + noteSecret = defaultRandomSecret() + h = hashK1(noteSecret) + } + const invoice = await requestInvoice( + info.callback, + grossMsat, + h ? {...options, h} : options, + ) const prepared: PreparedMint = { invoice: invoice.pr, verifyUrl: invoice.verify ?? null, + mode: named ? 'named' : 'unnamed', expectedNoteValueMsat: amountMsat, grossMsat, mintUrl: payUrl, @@ -97,15 +143,18 @@ export const prepareMint = async ( username: lightningAddressUsername(payUrl), nodeInfo, } + if (noteSecret) prepared.noteSecret = noteSecret if (info.mintPubkey) prepared.mintPubkey = info.mintPubkey return prepared } export type ClaimedNote = { note: NewBearer - // false when the rotate after claim failed - the note is tracked either - // way (it IS money), but the preimage was transmitted and the mint - // necessarily knows it, so an unrotated note must be treated as exposed + // false when the note may be exposed to someone besides this wallet and + // the mint: an unnamed mint's fresh note whose rotate failed (its k1 was + // the payment preimage, transmitted and mint-known). A named mint's + // note always reports true - its secret never rode an invoice, so there + // is nothing to rotate away from rotated: boolean // set when the rotate's answer was lost and the probe couldn't tell: the // possible rotated copy, to track unverified alongside `note` @@ -113,18 +162,119 @@ export type ClaimedNote = { rotationError?: string } -// Polls the mint invoice's LUD-21 verify URL until the payment settles, -// then claims the note: the payment preimage IS the note secret. The claim -// itself (an informational GET) puts that secret on the wire, and the mint -// has known it since it generated the invoice - so the fresh note is -// rotated immediately and unconditionally (observer race: anyone who saw -// the unpaid invoice knows the payment hash), before anything else happens -// with it. +// Polls a named mint output's informational GET until the note is there: +// 'minted' resolves the wait ('spent' resolves it too - surfaced for the +// caller to reject, never retried); 'unminted' and 'pending' keep it +// going. The poll IS the placement proof for a named output: the service +// only answers 'minted' for the secret it actually credited. +export const pollMintClaim = async ( + withdrawLink: string, + noteSecret: string, + poll: PollOptions = {}, + options: FundOperationOptions = {}, +): Promise => + pollUntil( + async (fetchOptions) => { + const claim = await fetchMintClaim(withdrawLink, noteSecret, fetchOptions) + return claim.state === 'minted' || claim.state === 'spent' ? claim : null + }, + 'Minted note not confirmed', + poll, + options, + ) + +// what a claim needs once the preimage is known - PreparedMint satisfies +// this, and so does the target side of an inter-mint transfer (see +// transfer.ts) +export type ClaimTarget = { + withdrawLink: string + // the net note value asked for - a claim, cross-checked against the + // service's authoritative maxWithdrawable + expectedNoteValueMsat: number + mintPubkey?: string +} + +// what a NAMED-mode claim needs: the wallet's own secret, plus the quote's +// verify URL and invoice as rescue anchors for a mint that took the hash +// but credited the preimage anyway. PreparedMint satisfies this. +export type NamedClaimTarget = ClaimTarget & { + noteSecret: string + verifyUrl?: string | null + invoice?: string +} + +// The named-mode claim: the note was credited to the wallet's own secret, +// which rode to the mint as a HASH inside the invoice request - the +// payment preimage opens nothing, so the claim needs no preimage and no +// rotate follows it. The rescue covers the mint that claimed the +// capability but credited the preimage (the kit's mintToHashIgnoresH +// adversary: the wallet was told no rotate is needed while the preimage +// is still the money): when that mint still serves the quote's verify, one +// check recovers the preimage and the note is claimed through the old +// path, WITH the rotate that path exists for. When it serves none there +// is nothing to rescue with - the error stays loud. +export const claimFromSecret = async ( + target: NamedClaimTarget, + poll: PollOptions = {}, + options: FundOperationOptions = {}, +): Promise => { + assertFundOwner(options) + let claim: MintClaim + try { + claim = await pollMintClaim(target.withdrawLink, target.noteSecret, poll, options) + } catch (err) { + if (!(err instanceof PollAbortedError) && target.verifyUrl && target.invoice) { + try { + const result = await fetchInvoiceVerification(target.verifyUrl, options) + if ( + result.settled && + sameInvoice(result.pr, target.invoice) && + result.preimage && + isPreimage(result.preimage) + ) { + return claimFromPreimage(target, result.preimage, options) + } + } catch { + // the rescue is best-effort - the original error stands + } + } + throw err + } + if (claim.state === 'spent') { + // a fresh wallet-chosen secret cannot already be spent - the service + // is lying about the note it just credited + throw new Error('The mint reports the freshly minted note as already spent.') + } + if (claim.amountMsat === null || !claim.callback) { + throw new Error('The mint did not return the minted note details.') + } + const note: NewBearer = { + url: buildNoteUrl(target.withdrawLink, claim.k1, claim.amountMsat), + callback: claim.callback, + amount: claim.amountMsat, + verified: true, + } + if (target.mintPubkey) note.mintPubkey = target.mintPubkey + return {note, rotated: true} +} + +// Polls until the minted note is claimable, then claims it. Unnamed mints: +// watch the LUD-21 verify URL for settlement, take the preimage (which IS +// the note secret there), and claim through claimFromPreimage with its +// mandatory rotate. Named mints: watch the note itself appear at the +// wallet's own secret (see claimFromSecret). export const claimMintedNote = async ( prepared: PreparedMint, poll: PollOptions = {}, options: FundOperationOptions = {}, ): Promise => { + if (prepared.mode === 'named') { + const noteSecret = prepared.noteSecret + if (!noteSecret) { + throw new Error('This mint was prepared in named mode without a note secret.') + } + return claimFromSecret({...prepared, noteSecret}, poll, options) + } if (!prepared.verifyUrl) { throw new Error( 'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.', @@ -144,17 +294,6 @@ export const claimMintedNote = async ( return claimFromPreimage(prepared, preimage, options) } -// what a claim needs once the preimage is known - PreparedMint satisfies -// this, and so does the target side of an inter-mint transfer (see -// transfer.ts) -export type ClaimTarget = { - withdrawLink: string - // the net note value asked for - a claim, cross-checked against the - // service's authoritative maxWithdrawable - expectedNoteValueMsat: number - mintPubkey?: string -} - // The claim itself, once the payment's preimage is known: the preimage IS // the note secret. The claim (an informational GET) puts that secret on // the wire, and the mint has known it since it generated the invoice - so From 67b2122e52399b85e4829a0b3b61224252ae8ebf Mon Sep 17 00:00:00 2001 From: protom Date: Mon, 31 Aug 2026 12:22:56 +0200 Subject: [PATCH 04/12] feat(lnurlcash): transfer to mints with wallet-named outputs A named target needs no verify URL: the note appearing at the wallet's own secret is the settlement proof, and it is claimed without a rotate. The claim material carries the secret - and the unverified note at it - from the start, so a melt settling after the poll budget is never a lost note; the source-note oracle classification is unchanged. --- src/lnurlcash/ops.transfer.cases.ts | 81 +++++++++++++++++- src/lnurlcash/ops/transfer.ts | 128 +++++++++++++++++++++++++--- 2 files changed, 196 insertions(+), 13 deletions(-) diff --git a/src/lnurlcash/ops.transfer.cases.ts b/src/lnurlcash/ops.transfer.cases.ts index 6cedf05..131f1b4 100644 --- a/src/lnurlcash/ops.transfer.cases.ts +++ b/src/lnurlcash/ops.transfer.cases.ts @@ -1,6 +1,6 @@ import {describe, expect, it} from 'vitest' import {createMockMint} from 'lnurlcash-conformance/mock-mint' -import {noteK1} from 'lnurlcash-kit' +import {hashK1, noteK1} from 'lnurlcash-kit' import {transferBetweenMints} from './ops' import {requiredValue} from './test-utils' @@ -133,4 +133,83 @@ describe('transferBetweenMints', () => { expect(source.state.noteState(returnedK1)).toBe('outstanding') expect(result.carve.note.amount).toBe(21_000) }) + + it('moves value to a NAMED target: the note lands at the wallet secret, no rotate', async () => { + const source = await mint() + const target = await mint({commentAllowed: 64, testHooks: true}) + const k1 = secret('48') + const bearer = await makeBearer(source, k1, 21_000) + const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { + poll: fastPoll, + }) + const preimage = await settleWhenRequested(target) + const result = await pending + expect(result.outcome).toBe('settled') + await expectBurned(source, k1) + const claimed = requiredValue(result.mintedAtTarget) + expect(claimed.rotated).toBe(true) + expect(claimed.note.amount).toBe(21_000) + expect(claimed.note.verified).toBe(true) + const newK1 = requiredValue(noteK1(claimed.note.url)) + // the note stands at the wallet's own secret - the quote was bound to + // its hash, and the payment preimage keys nothing at the target + expect([...target.state.invoices.values()].at(-1)?.boundTo).toBe(hashK1(newK1)) + expect(target.state.noteState(newK1)).toBe('outstanding') + expect(target.state.noteState(preimage)).toBeNull() + }) + + it('keeps the named claim material when the target never credits the note', async () => { + const source = await mint({meltNeverSettles: true}) + const target = await mint({commentAllowed: 64, testHooks: true}) + const k1 = secret('49') + const bearer = await makeBearer(source, k1, 21_000) + const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { + poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300}, + }) + // the target invoice exists (the quote is bound) but the source melt + // never lands, so nothing is ever credited at the wallet's secret + while (target.state.invoices.size === 0) { + await new Promise((resolve) => setTimeout(resolve, 10)) + } + const result = await pending + expect(result.outcome).toBe('unknown-still-pending') + expect(result.mintedAtTarget).toBeUndefined() + // the wallet's secret is the way back to the money if the melt's + // payment arrives later - and the note at that secret is tracked + // unverified, so a late settlement is never a lost note + const noteSecret = requiredValue(result.claimMaterial?.noteSecret) + expect(noteSecret).toMatch(/^[0-9a-f]{64}$/) + const material = requiredValue(result.claimMaterial?.note) + expect(material.verified).toBe(false) + expect(noteK1(material.url)).toBe(noteSecret) + }) + + it('rescues a named transfer through the preimage when the target credited it anyway', async () => { + const source = await mint() + // the mintToHashIgnoresH adversary: advertises naming, credits the + // payment hash - and (non-compliantly) still serves verify + const target = await mint({ + commentAllowed: 64, + mintToHashIgnoresH: true, + verifyOnUnnamedMint: true, + testHooks: true, + }) + const k1 = secret('50') + const bearer = await makeBearer(source, k1, 21_000) + const pending = transferBetweenMints([bearer], 21_000, `mint@127.0.0.1:${target.port}`, { + poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 400}, + }) + const preimage = await settleWhenRequested(target) + const result = await pending + expect(result.outcome).toBe('settled') + await expectBurned(source, k1) + const claimed = requiredValue(result.mintedAtTarget) + expect(claimed.rotated).toBe(true) + // claimed through the preimage path and rotated off it, because that + // preimage rode the invoice + expect(target.state.noteState(preimage)).toBe('burned') + const newK1 = requiredValue(noteK1(claimed.note.url)) + expect(newK1).not.toBe(preimage) + expect(target.state.noteState(newK1)).toBe('outstanding') + }) }) diff --git a/src/lnurlcash/ops/transfer.ts b/src/lnurlcash/ops/transfer.ts index db49837..a72abc6 100644 --- a/src/lnurlcash/ops/transfer.ts +++ b/src/lnurlcash/ops/transfer.ts @@ -3,13 +3,24 @@ // that exist: this wallet requests an invoice FROM the target mint // (grossed up for its advertised mint fee, so the note that comes out // nets the requested amount), melts source notes to pay it, then claims -// the target note from the revealed preimage exactly like any other -// minted receive. The target invoice settling is the transfer's ground -// truth: it can only settle if the source melt's payment arrived, and its -// verify response is what reveals the preimage to claim with. When it -// never settles, the source note itself is the oracle - a successful -// rotate proves the melt never burned it (funds returned), anything else -// stays uncertain. +// the target note exactly like any other minted receive. The target +// invoice settling is the transfer's ground truth: it can only settle if +// the source melt's payment arrived. How settling is observed and claimed +// depends on the target's minting mode (see mint.ts): +// +// - UNNAMED target: the verify URL is the observation - its settled +// response reveals the preimage, which IS the note's secret, and the +// claim rotates off it immediately. +// - NAMED target: the note itself is the observation - the wallet named +// the output with its own secret at quote time, so the informational +// GET reporting 'minted' IS the settlement proof, and the note is +// already at a secret nobody else has seen. No verify URL is needed +// (but one is used to rescue the mint that took the hash and credited +// the preimage anyway). +// +// When settlement never confirms, the source note itself is the oracle - +// a successful rotate proves the melt never burned it (funds returned), +// anything else stays uncertain. import { AmbiguousMutationError, @@ -17,6 +28,7 @@ import { PendingNoteError, buildNoteUrl, decodeBolt11AmountMsat, + fetchInvoiceVerification, isPreimage, meltNote, noteK1, @@ -30,8 +42,8 @@ import type {LnurlcashOptions} from 'lnurlcash-kit' import type {Bearer, NewBearer} from '../types' import type {CarveResult} from './carve' import {ensureExactAmount} from './carve' -import type {ClaimedNote} from './mint' -import {claimFromPreimage, prepareMint} from './mint' +import type {ClaimedNote, PreparedMint} from './mint' +import {claimFromPreimage, pollMintClaim, prepareMint} from './mint' import type {PollOptions} from './shared' import type {FundOperationOptions} from './shared' import {assertFundOwner, pollVerifyUntilSettled} from './shared' @@ -76,6 +88,10 @@ export type TransferClaimMaterial = { // the preimage note, unverified, once the preimage is known - the // preimage IS the note secret; the caller must track it and retry note?: NewBearer + // named target only: the wallet-chosen secret the target note lands at. + // The note exists at this secret from the moment the invoice settles - + // the caller can rebuild the note URL from it and retry the claim + noteSecret?: string } export type TransferResult = { @@ -85,8 +101,9 @@ export type TransferResult = { quote: TransferQuote // the invoice the source note was melted to pay invoice: string - // the target invoice's verify URL - the transfer's ground truth - verifyUrl: string + // the target invoice's verify URL - an unnamed target's ground truth + // (a named target may not serve one at all; the note itself is observed) + verifyUrl: string | null sourceServer: string targetServer: string // the fresh target note, on 'settled' @@ -106,6 +123,19 @@ export type TransferOptions = { assertOwner?: () => void } +// the claim retry material for a named target: the note at the wallet's +// own secret, unverified until a claim succeeds +const namedClaimNote = (prepared: PreparedMint, noteSecret: string): NewBearer => { + const note: NewBearer = { + url: buildNoteUrl(prepared.withdrawLink, noteSecret, prepared.expectedNoteValueMsat), + callback: '', + amount: prepared.expectedNoteValueMsat, + verified: false, + } + if (prepared.mintPubkey) note.mintPubkey = prepared.mintPubkey + return note +} + export const transferBetweenMints = async ( bearers: Bearer[], amountMsat: number, @@ -120,7 +150,7 @@ export const transferBetweenMints = async ( // TARGET mint - a failure here (unreachable, no minting support, amount // out of range) leaves every source note untouched const prepared = await prepareMint(targetMint, amountMsat, options) - if (!prepared.verifyUrl) { + if (prepared.mode === 'unnamed' && !prepared.verifyUrl) { throw new Error( 'The target mint did not advertise a verify URL - a transfer there cannot auto-claim.', ) @@ -153,6 +183,13 @@ export const transferBetweenMints = async ( withdrawLink: prepared.withdrawLink, expectedNoteValueMsat: prepared.expectedNoteValueMsat, } + if (prepared.noteSecret) { + claimMaterial.noteSecret = prepared.noteSecret + // a named target's note is claimable from the wallet's secret alone, + // the moment the invoice settles - track it unverified from the start, + // so a melt that settles after the budget is never a lost note + claimMaterial.note = namedClaimNote(prepared, prepared.noteSecret) + } // from here on the carve's fresh secrets exist only in this result - the // flow never throws again; every outcome carries them const base = {carve, quote, invoice, verifyUrl, sourceServer, targetServer} @@ -171,6 +208,73 @@ export const transferBetweenMints = async ( // payment arrived, and the source probe tells the rest } try { + if (prepared.mode === 'named') { + const noteSecret = prepared.noteSecret + if (!noteSecret) throw new Error('The target mint was prepared without a note secret.') + // the note appearing at the wallet's own secret IS the settlement + // proof - the target only credits it once the melt's payment landed + try { + const claim = await pollMintClaim(prepared.withdrawLink, noteSecret, poll, options) + if (claim.state !== 'minted' || claim.amountMsat === null || !claim.callback) { + // 'spent' on a fresh wallet-chosen secret, or an incomplete + // answer: the target misbehaved - the way back to the note is + // already in the claim material + return {...base, outcome: 'settled-claim-failed', claimMaterial} + } + const note: NewBearer = { + url: buildNoteUrl(prepared.withdrawLink, claim.k1, claim.amountMsat), + callback: claim.callback, + amount: claim.amountMsat, + verified: true, + } + if (prepared.mintPubkey) note.mintPubkey = prepared.mintPubkey + // no rotate: the secret never rode an invoice (see mint.ts) + return {...base, outcome: 'settled', mintedAtTarget: {note, rotated: true}} + } catch (err) { + // the note never appeared at the wallet's secret within budget. + // Rescue through the quote's verify when the target serves one: + // a mint that took the hash but credited the PREIMAGE (the + // mintToHashIgnoresH adversary) is claimable through the old path + if (verifyUrl) { + try { + const proof = await fetchInvoiceVerification(verifyUrl, options) + if (proof.settled && sameInvoice(proof.pr, invoice) && proof.preimage) { + if (isPreimage(proof.preimage)) { + try { + const claimed = await claimFromPreimage(prepared, proof.preimage, options) + return {...base, outcome: 'settled', mintedAtTarget: claimed} + } catch { + const note: NewBearer = { + url: buildNoteUrl( + prepared.withdrawLink, + proof.preimage, + prepared.expectedNoteValueMsat, + ), + callback: '', + amount: prepared.expectedNoteValueMsat, + verified: false, + } + if (prepared.mintPubkey) note.mintPubkey = prepared.mintPubkey + return { + ...base, + outcome: 'settled-claim-failed', + claimMaterial: {...claimMaterial, note}, + } + } + } + } + } catch { + // best-effort rescue - fall through to the source-note oracle + } + } + throw err + } + } + if (!verifyUrl) { + throw new Error( + 'The target mint did not advertise a verify URL - a transfer there cannot auto-claim.', + ) + } const proof = await pollVerifyUntilSettled(verifyUrl, poll, options) // the proof-binding rule from pay.ts, extended for the gross-up: the // verify URL is scoped to this invoice's payment hash, so an exact pr From c9963934aa6bc384cd9742e099b2c72b1f69e400 Mon Sep 17 00:00:00 2001 From: protom Date: Mon, 31 Aug 2026 12:22:56 +0200 Subject: [PATCH 05/12] feat(funds): keep the claimable note of a pending named transfer An unknown-still-pending transfer to a named mint carries the note at the wallet's own secret in its claim material - persist it unverified like the settled-claim-failed path already does, instead of dropping it with the result object. --- src/composables/useMoveFundsPage.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/composables/useMoveFundsPage.ts b/src/composables/useMoveFundsPage.ts index c8f0e47..91365de 100644 --- a/src/composables/useMoveFundsPage.ts +++ b/src/composables/useMoveFundsPage.ts @@ -170,6 +170,12 @@ export const useMoveFundsPage = () => { ); } else if (transfer.outcome === 'unknown-still-pending') { await wallet.markSpent(carved.id, ownerFence); + // a named target's claim material carries the note at the wallet's + // own secret - claimable the moment the melt settles, so it is + // tracked unverified rather than lost with the result object + if (transfer.claimMaterial?.note) { + await addCommittedBearers(wallet, [transfer.claimMaterial.note], commitContext); + } await activity.log( 'transfer', `A move of ${sats.toLocaleString()} sats to ${transfer.targetServer} is still in flight - the note is locked.`, From ae5064b69a545a9ade671b5d701d5399fe3517ed Mon Sep 17 00:00:00 2001 From: protom Date: Mon, 31 Aug 2026 12:23:10 +0200 Subject: [PATCH 06/12] 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. --- src/lnurlcash/nwc.invoice-a.cases.ts | 125 ++++++++++++++++++++++++++- src/lnurlcash/nwc/invoices.ts | 88 ++++++++++++++++--- 2 files changed, 199 insertions(+), 14 deletions(-) diff --git a/src/lnurlcash/nwc.invoice-a.cases.ts b/src/lnurlcash/nwc.invoice-a.cases.ts index 77b39cb..109e548 100644 --- a/src/lnurlcash/nwc.invoice-a.cases.ts +++ b/src/lnurlcash/nwc.invoice-a.cases.ts @@ -11,7 +11,7 @@ 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 {buildNoteUrl, fetchNoteInfo, hashK1, noteK1} from 'lnurlcash-kit' import {createMockMint} from 'lnurlcash-conformance/mock-mint' import { @@ -124,6 +124,129 @@ 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/invoices.ts b/src/lnurlcash/nwc/invoices.ts index 1fc1a5b..e1a8caa 100644 --- a/src/lnurlcash/nwc/invoices.ts +++ b/src/lnurlcash/nwc/invoices.ts @@ -1,15 +1,18 @@ // 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 (rotating it immediately), and only THEN records the -// settlement the lookup reports. +// the minted note, and only THEN records the settlement the lookup +// reports. // -// THE PREIMAGE, in lnurlcash terms: 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. +// 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. import {isPreimage, sameInvoice} from 'lnurlcash-kit' 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 {PreparedMint} from '../ops' -import {claimFromPreimage} from '../ops/mint' +import {claimFromPreimage, claimFromSecret} from '../ops/mint' import {pollVerifyUntilSettled} from '../ops/shared' import type {PollOptions} from '../ops/shared' @@ -61,13 +64,14 @@ export const invoiceResult = (entry: PendingInvoice): Record => } // 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 and -// bearer commit - so lookup can only reveal durably tracked funds and an -// already-burned secret. +// 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.', @@ -114,3 +118,61 @@ export const settleAndClaim = async (ctx: RequestContext, entry: PendingInvoice) 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) + 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 +} From 39a144cf50d621908b79cc27c7c664f6f9fc369c Mon Sep 17 00:00:00 2001 From: protom Date: Mon, 31 Aug 2026 12:23:10 +0200 Subject: [PATCH 07/12] feat(receive): accept named mints that serve no verify URL Named mints claim through the wallet's own secret, so a missing LUD-21 verify URL no longer blocks receiving; unnamed mints still require it - there the preimage it reveals IS the claim. --- src/composables/useReceiveLightningDialog.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/composables/useReceiveLightningDialog.ts b/src/composables/useReceiveLightningDialog.ts index a44bdc5..619efce 100644 --- a/src/composables/useReceiveLightningDialog.ts +++ b/src/composables/useReceiveLightningDialog.ts @@ -159,7 +159,10 @@ export const useReceiveLightningDialog = ( try { const input = mintChoice.value === CUSTOM_MINT ? customMint.value.trim() : mintChoice.value; const next = await prepareMint(input, satsToMsat(sats)); - if (!next.verifyUrl) { + // named mints claim through the wallet's own secret and may + // legitimately serve no verify URL; unnamed mints need one (the + // preimage it reveals IS the claim) + if (next.mode === 'unnamed' && !next.verifyUrl) { formError.value = 'This mint does not support automatic claiming, so sattle cannot receive from it. Choose a different mint.'; return; From 637376e2919ef9e753beaaca0de5d0c26c12e2f6 Mon Sep 17 00:00:00 2001 From: protom Date: Mon, 31 Aug 2026 12:23:10 +0200 Subject: [PATCH 08/12] docs(lnurlcash): document the two mint output modes Reword the rotate-after-claim invariant for named outputs and fix the test-harness pointer (ops.testHarness.ts, not test-utils.ts). --- src/lnurlcash/AGENTS.md | 14 ++++++++++++-- src/lnurlcash/ops.ts | 12 +++++++----- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/lnurlcash/AGENTS.md b/src/lnurlcash/AGENTS.md index eca2b44..b5d8982 100644 --- a/src/lnurlcash/AGENTS.md +++ b/src/lnurlcash/AGENTS.md @@ -6,7 +6,14 @@ lnurlcash-kit directly. ## FUND-CRITICAL INVARIANTS (non-negotiable) -- Rotate on every receive, and immediately after claiming a fresh mint. +- Rotate on every receive, and immediately after claiming a fresh mint + from an UNNAMED mint (one that keys the note by the payment preimage). + A NAMED mint (LUD-25 `comment`/`mintToHash`, required by lnurl-mint + ≥ 0.4) credits the note to a wallet-chosen secret whose hash bound the + quote; that secret never rode an invoice, so the claim needs no rotate. + The claim poll itself is the placement proof: a mint that took the hash + but credited the preimage is caught there and rescued through the + quote's verify URL when one is served. - A note's declared amount is a claim; the service's `maxWithdrawable` is authoritative. - A melt's "OK" only means the payment is in flight — the verify URL (or @@ -61,4 +68,7 @@ test-utils.ts # mock mint harness used by *.test.ts ## TESTS `npx vitest run` — node environment, `*.test.ts` next to the modules. -Adversarial mock mint from lnurlcash-conformance via test-utils. +Adversarial mock mint from lnurlcash-conformance via `ops.testHarness.ts` +(`conformance-mockmint.d.ts` augments its stale published declarations +with the comment-naming flags). The mock's `commentAllowed` option models +the named-mint generation; default options model the old one. diff --git a/src/lnurlcash/ops.ts b/src/lnurlcash/ops.ts index 1efaa5d..af69955 100644 --- a/src/lnurlcash/ops.ts +++ b/src/lnurlcash/ops.ts @@ -6,9 +6,11 @@ // // Fund-critical invariants enforced across the flows (see the project plan): // - rotate on every receive, and immediately after claiming a fresh mint -// (observer race: anyone who saw the unpaid invoice knows the payment -// hash, and the mint necessarily saw the preimage - the preimage IS the -// note secret) +// from an UNNAMED mint (observer race: anyone who saw the unpaid invoice +// knows the payment hash, and the mint necessarily saw the preimage - +// the preimage IS the note secret). A NAMED mint (LUD-25 comment / +// mintToHash) credits the note to a wallet-chosen secret instead; that +// secret never rode an invoice, so no rotate follows its claim // - a note's declared amount is a claim; the service's maxWithdrawable is // authoritative // - a melt's "OK" only means the payment is in flight; its verify URL (or @@ -23,8 +25,8 @@ // ops/receiveBearer.ts - receiveBearer (receive a note, rotate on receive) // ops/pay.ts - payWithBearers (melt to bolt11 / Lightning Address) // ops/transfer.ts - transferBetweenMints (inter-mint move: melt at -// source, mint + claim + rotate at target) -// ops/shared.ts - bounded verify polling, UncertainOutcomeError +// source, mint + claim at target) +// ops/shared.ts - bounded polling, UncertainOutcomeError export {UncertainOutcomeError} from './ops/shared' export type {PollOptions} from './ops/shared' From 6bb07ff9ec5c69b01c1822471c24312b44707927 Mon Sep 17 00:00:00 2001 From: protom Date: Mon, 31 Aug 2026 12:54:46 +0200 Subject: [PATCH 09/12] fix(lnurlcash): assert named claims land at the wallet's own secret Review feedback: bind the claim answer to the secret that was polled before building a note from it. The kit already guarantees this - claimMintedNote returns the queried secret by construction and fetchNoteInfo rejects a mismatched k1 echo - so the assert cannot trip through the kit (and is therefore not separately testable); it exists so a future kit regression fails loudly instead of tracking a wrong secret. --- src/lnurlcash/ops/mint.ts | 7 +++++++ src/lnurlcash/ops/transfer.ts | 16 ++++++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/lnurlcash/ops/mint.ts b/src/lnurlcash/ops/mint.ts index ad04023..5abd23b 100644 --- a/src/lnurlcash/ops/mint.ts +++ b/src/lnurlcash/ops/mint.ts @@ -245,6 +245,13 @@ export const claimFromSecret = async ( // is lying about the note it just credited throw new Error('The mint reports the freshly minted note as already spent.') } + if (claim.k1 !== target.noteSecret) { + // belt and suspenders: the kit's claimMintedNote returns the queried + // secret by construction and fetchNoteInfo rejects a mismatched echo, + // so this cannot trip through the kit - if it ever does, building a + // note from the answer would track the wrong secret + throw new Error('The mint answered the claim for a different note secret.') + } if (claim.amountMsat === null || !claim.callback) { throw new Error('The mint did not return the minted note details.') } diff --git a/src/lnurlcash/ops/transfer.ts b/src/lnurlcash/ops/transfer.ts index a72abc6..6141214 100644 --- a/src/lnurlcash/ops/transfer.ts +++ b/src/lnurlcash/ops/transfer.ts @@ -215,10 +215,18 @@ export const transferBetweenMints = async ( // proof - the target only credits it once the melt's payment landed try { const claim = await pollMintClaim(prepared.withdrawLink, noteSecret, poll, options) - if (claim.state !== 'minted' || claim.amountMsat === null || !claim.callback) { - // 'spent' on a fresh wallet-chosen secret, or an incomplete - // answer: the target misbehaved - the way back to the note is - // already in the claim material + if ( + claim.state !== 'minted' || + claim.k1 !== noteSecret || + claim.amountMsat === null || + !claim.callback + ) { + // 'spent' on a fresh wallet-chosen secret, a claim naming a + // secret other than the one polled (impossible through the kit, + // which returns the queried secret and rejects mismatched + // echoes - asserted anyway: building from it would track the + // wrong note), or an incomplete answer: the target misbehaved - + // the way back to the note is already in the claim material return {...base, outcome: 'settled-claim-failed', claimMaterial} } const note: NewBearer = { From 4cc8e0aba2bd7ef8e4ea7d1b0ee9b98f21d19ee7 Mon Sep 17 00:00:00 2001 From: protom Date: Mon, 31 Aug 2026 13:31:36 +0200 Subject: [PATCH 10/12] fix: replace forgesworn with moneyer in the public mint suggestions @mint.forgesworn.dev is shutting down; @moneyer.dev takes its place in the curated quick-start list. --- src/lnurlcash/trustedMints.test.ts | 4 ++-- src/lnurlcash/trustedMints.ts | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/lnurlcash/trustedMints.test.ts b/src/lnurlcash/trustedMints.test.ts index 28b88e9..33611f3 100644 --- a/src/lnurlcash/trustedMints.test.ts +++ b/src/lnurlcash/trustedMints.test.ts @@ -213,11 +213,11 @@ describe('security policy characterization', () => { }) describe('PUBLIC_MINTS', () => { - it('is the curated quick-start list, ported verbatim from lnurl-wallet', () => { + it('is the curated quick-start list, ported from lnurl-wallet', () => { expect(PUBLIC_MINTS).toEqual([ '@mint.600.wtf', '@lnurl.21mint.me', - '@mint.forgesworn.dev', + '@moneyer.dev', '@lnurl.21linz.at', '@minty.exe.xyz', ]) diff --git a/src/lnurlcash/trustedMints.ts b/src/lnurlcash/trustedMints.ts index 6d903b5..1fa8ac7 100644 --- a/src/lnurlcash/trustedMints.ts +++ b/src/lnurlcash/trustedMints.ts @@ -107,11 +107,12 @@ export const mintAddressCacheInfo = ( // whether this wallet has ever used it). The bare "@domain" form rather // than spelling out "mint@domain" - still resolves to the exact same // address, just how these mints tend to actually display their own. -// Ported verbatim from lnurl-wallet/src/trustedMints.ts. +// Ported from lnurl-wallet/src/trustedMints.ts; diverges where the public +// mint landscape did (@mint.forgesworn.dev shut down, @moneyer.dev added). export const PUBLIC_MINTS = [ '@mint.600.wtf', '@lnurl.21mint.me', - '@mint.forgesworn.dev', + '@moneyer.dev', '@lnurl.21linz.at', '@minty.exe.xyz', ] From 48be23dceb940b28166b242ff308600864b87dd2 Mon Sep 17 00:00:00 2001 From: protom Date: Mon, 31 Aug 2026 14:03:15 +0200 Subject: [PATCH 11/12] feat(lnurlcash): probe would-be outputs before believing a mutation refusal The redeem callback is a GET and HTTP stacks retry GETs, so a mint can execute the first attempt and refuse the repeat as an already-spent input (LUD-25 says SHOULD replay; real mints refuse). The kit attaches the fresh output secrets to every refusal - probeMutationOutput tells a landed mutation from a genuine refusal. The harness wrapper reproduces the retry against the mock. --- src/lnurlcash/ops.testHarness.ts | 19 +++++++++++++++++++ src/lnurlcash/ops/shared.ts | 29 ++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/lnurlcash/ops.testHarness.ts b/src/lnurlcash/ops.testHarness.ts index 98b1166..ee6a1b1 100644 --- a/src/lnurlcash/ops.testHarness.ts +++ b/src/lnurlcash/ops.testHarness.ts @@ -69,3 +69,22 @@ export const expectBurned = async (instance: Mint, k1: string): Promise => } expect(instance.state.noteState(k1)).toBe('burned') } + +// a fetch wrapper that answers every /w/cb request with the response to +// its SECOND, byte-identical send - the HTTP-stack GET retry a real client +// is subject to: the mint executes the first attempt and (with the mock's +// default retriedMutation: 'refuse', which is also lnurl-mint's behavior) +// refuses the repeat as an already-spent input, which is all the caller +// ever sees +export const retryingCbFetch = (): typeof fetch => { + const impl: typeof fetch = async (input, init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + if (url.includes('/w/cb')) { + const first = await fetch(input, init) + await first.arrayBuffer() + return fetch(input, init) + } + return fetch(input, init) + } + return impl +} diff --git a/src/lnurlcash/ops/shared.ts b/src/lnurlcash/ops/shared.ts index d20a70f..62f989a 100644 --- a/src/lnurlcash/ops/shared.ts +++ b/src/lnurlcash/ops/shared.ts @@ -2,8 +2,9 @@ // every flow that waits on a payment uses, and the uncertainty type a lost // mutation answer surfaces as. -import {fetchInvoiceVerification} from 'lnurlcash-kit' +import {fetchInvoiceVerification, fetchNoteInfo, withNewK1} from 'lnurlcash-kit' import type {LnurlcashOptions, VerifyResult} from 'lnurlcash-kit' +import {NoteSpentError, NoteUnknownError, noteDeclaredAmount} from 'lnurlcash-kit' import type {NewBearer} from '../types' // a mutation's answer was lost AND the probe could not tell whether it @@ -36,6 +37,32 @@ export const assertFundOwner = (options: FundOperationOptions): void => { options.assertOwner?.() } +// what probing a would-be mutation output can tell +export type OutputProbe = 'live' | 'absent' | 'unknown' + +// A refused mutation can still have LANDED: the redeem callback is a GET +// and HTTP stacks retry GETs, so the service may have executed the first +// attempt and refused this one as an already-spent input (LUD-25 says a +// byte-identical retry SHOULD get the original success replayed; real +// mints refuse instead). The kit attaches the fresh output secrets to +// every service refusal (newSecretsOf) - so before believing a refusal, +// probe one would-be output: 'live' proves the mutation landed and the +// carried secrets are the only money left, 'absent' proves the refusal is +// genuine, 'unknown' keeps it genuinely ambiguous. +export const probeMutationOutput = async ( + noteUrl: string, + secret: string, + options: LnurlcashOptions = {}, +): Promise => { + try { + await fetchNoteInfo(withNewK1(noteUrl, secret, noteDeclaredAmount(noteUrl) ?? 0), options) + return 'live' + } catch (err) { + if (err instanceof NoteSpentError || err instanceof NoteUnknownError) return 'absent' + return 'unknown' + } +} + export type PollOptions = { // first delay between checks (doubles each round up to intervalCapMs) intervalMs?: number From 50b2427b8db07692a590ff2602a034385bbac7df Mon Sep 17 00:00:00 2001 From: protom Date: Mon, 31 Aug 2026 14:03:15 +0200 Subject: [PATCH 12/12] fix(lnurlcash): rescue landed mutations from retry refusals A split, merge or rotate whose answer is 'Invalid or already spent k1.' may be the refusal of a retried request that already LANDED - believing it strands the fresh outputs server-side and desyncs the wallet (the input is burned at the mint but still spendable locally, so every later operation fails). All three flows now probe a would-be output when a refusal carries fresh secrets: live outputs are adopted (or surfaced unverified when the probe can't tell), a genuine refusal still fails. --- src/lnurlcash/ops.retry-refusal.cases.ts | 90 +++++++++++++++ src/lnurlcash/ops.test.ts | 1 + src/lnurlcash/ops/carve.ts | 141 ++++++++++++++++------- src/lnurlcash/ops/mint.ts | 40 ++++++- src/lnurlcash/ops/receiveBearer.ts | 39 ++++++- src/lnurlcash/receive.ts | 20 ++-- 6 files changed, 281 insertions(+), 50 deletions(-) create mode 100644 src/lnurlcash/ops.retry-refusal.cases.ts diff --git a/src/lnurlcash/ops.retry-refusal.cases.ts b/src/lnurlcash/ops.retry-refusal.cases.ts new file mode 100644 index 0000000..9cf73c4 --- /dev/null +++ b/src/lnurlcash/ops.retry-refusal.cases.ts @@ -0,0 +1,90 @@ +import {describe, expect, it} from 'vitest' +import {noteK1} from 'lnurlcash-kit' + +import {ensureExactAmount, receiveBearer} from './ops' +import {claimFromPreimage, prepareMint} from './ops/mint' +import {requiredValue} from './test-utils' +import { + makeBearer, + mint, + noteUrl, + retryingCbFetch, + secret, + settleLastInvoice, +} from './ops.testHarness' + +// The redeem callback is a GET, and HTTP stacks retry GETs - so a mint +// can execute the first attempt of a mutation and refuse the byte-identical +// repeat as an already-spent input, which is the only answer the wallet +// ever sees. Every mutation that carries fresh secrets must probe the +// would-be outputs before believing such a refusal: landed means the +// carried secrets are the only money left. +describe('mutation retry refusals', () => { + it('rescues the outputs when the split answer is a retry refusal', async () => { + const instance = await mint() + const k1 = secret('60') + const bearer = await makeBearer(instance, k1, 50_000) + const result = await ensureExactAmount([bearer], 25_000, {fetch: retryingCbFetch()}) + // the split landed on the first (unseen) attempt: the input is burned + // and both outputs are live under the secrets the wallet carried + expect(instance.state.noteState(k1)).toBe('burned') + expect(result.consumed).toHaveLength(1) + const partK1 = requiredValue(noteK1(result.note.url)) + expect(partK1).not.toBe(k1) + expect(result.note.amount).toBe(25_000) + expect(instance.state.noteState(partK1)).toBe('outstanding') + const changeK1 = requiredValue(noteK1(requiredValue(result.change).url)) + expect(instance.state.noteState(changeK1)).toBe('outstanding') + }) + + it('rescues the combined note when the merge answer is a retry refusal', async () => { + const instance = await mint() + const a = await makeBearer(instance, secret('61'), 20_000) + const b = await makeBearer(instance, secret('62'), 5_000) + const result = await ensureExactAmount([a, b], 25_000, {fetch: retryingCbFetch()}) + expect(result.consumed).toHaveLength(2) + expect(instance.state.noteState(secret('61'))).toBe('burned') + expect(instance.state.noteState(secret('62'))).toBe('burned') + const mergedK1 = requiredValue(noteK1(result.note.url)) + expect(result.note.amount).toBe(25_000) + expect(instance.state.noteState(mergedK1)).toBe('outstanding') + }) + + it('adopts the fresh secret when the claim rotate answer is a retry refusal', async () => { + const instance = await mint({testHooks: true}) + const prepared = await prepareMint(`mint@127.0.0.1:${instance.port}`, 21_000) + const preimage = await settleLastInvoice(instance) + const claimed = await claimFromPreimage(prepared, preimage, {fetch: retryingCbFetch()}) + expect(claimed.rotated).toBe(true) + expect(claimed.note.verified).toBe(true) + expect(instance.state.noteState(preimage)).toBe('burned') + const k1 = requiredValue(noteK1(claimed.note.url)) + expect(k1).not.toBe(preimage) + expect(instance.state.noteState(k1)).toBe('outstanding') + }) + + it('adopts the fresh secret when the receive rotate answer is a retry refusal', async () => { + const instance = await mint() + const senderK1 = secret('63') + instance.state.creditNote(senderK1, 21_000) + const received = await receiveBearer(noteUrl(instance, senderK1, 21_000), [], { + fetch: retryingCbFetch(), + }) + expect(received.rotated).toBe(true) + expect(received.note.amount).toBe(21_000) + expect(instance.state.noteState(senderK1)).toBe('burned') + const k1 = requiredValue(noteK1(received.note.url)) + expect(k1).not.toBe(senderK1) + expect(instance.state.noteState(k1)).toBe('outstanding') + }) + + it('still fails a genuine refusal - a note spent before the split', async () => { + const instance = await mint() + const k1 = secret('64') + const bearer = await makeBearer(instance, k1, 50_000) + instance.state.settleMelt(k1) // burned elsewhere, before we ever asked + await expect( + ensureExactAmount([bearer], 25_000, {fetch: retryingCbFetch()}), + ).rejects.toThrow(/spent/i) + }) +}) diff --git a/src/lnurlcash/ops.test.ts b/src/lnurlcash/ops.test.ts index 1311cb4..97ce1bf 100644 --- a/src/lnurlcash/ops.test.ts +++ b/src/lnurlcash/ops.test.ts @@ -2,4 +2,5 @@ import './ops.carve.cases' import './ops.mint-named.cases' import './ops.mint-receive.cases' import './ops.pay.cases' +import './ops.retry-refusal.cases' import './ops.transfer.cases' diff --git a/src/lnurlcash/ops/carve.ts b/src/lnurlcash/ops/carve.ts index 780cd30..265eed2 100644 --- a/src/lnurlcash/ops/carve.ts +++ b/src/lnurlcash/ops/carve.ts @@ -5,6 +5,7 @@ import { AmbiguousMutationError, mergeNotes, + newSecretsOf, noteK1, probeBurnedNote, requireNoteK1, @@ -15,7 +16,7 @@ import { } from 'lnurlcash-kit' import type {Bearer, NewBearer} from '../types' import type {FundOperationOptions} from './shared' -import {assertFundOwner, UncertainOutcomeError} from './shared' +import {assertFundOwner, probeMutationOutput, UncertainOutcomeError} from './shared' // the changeset stores apply after a mutation: `note`/`change` BEFORE // `consumed` - the mint call already burned every consumed input @@ -151,38 +152,78 @@ export const ensureExactAmount = async ( changeSignature = parts.changeSignature partVerified = true } catch (err) { - if (!(err instanceof AmbiguousMutationError)) throw err - // the split request may have landed despite the failure - probe one - // input before deciding what the carried secrets are worth - const outcome = await probeBurnedNote(base.url, options) - if (outcome === 'live') throw err // nothing burned - a plain failure - if (outcome === 'unknown') { - // can't tell: surface both possible outputs unverified WITHOUT - // consuming the inputs, and stop here rather than spend from limbo - throw new UncertainOutcomeError( - 'The split may have gone through but could not be confirmed - the possible outputs must be tracked unverified alongside the originals until refreshed.', - [ - { - url: withNewK1(base.url, err.newSecrets[0], amountMsat), - callback: base.callback, - amount: amountMsat, - verified: false, - mintPubkey: base.mintPubkey, - }, - { - url: withNewK1(base.url, err.newSecrets[1], total - amountMsat), - callback: base.callback, - amount: total - amountMsat, - verified: false, - mintPubkey: base.mintPubkey, - }, - ], - ) + if (err instanceof AmbiguousMutationError) { + // the split request may have landed despite the failure - probe one + // input before deciding what the carried secrets are worth + const outcome = await probeBurnedNote(base.url, options) + if (outcome === 'live') throw err // nothing burned - a plain failure + if (outcome === 'unknown') { + // can't tell: surface both possible outputs unverified WITHOUT + // consuming the inputs, and stop here rather than spend from limbo + throw new UncertainOutcomeError( + 'The split may have gone through but could not be confirmed - the possible outputs must be tracked unverified alongside the originals until refreshed.', + [ + { + url: withNewK1(base.url, err.newSecrets[0], amountMsat), + callback: base.callback, + amount: amountMsat, + verified: false, + mintPubkey: base.mintPubkey, + }, + { + url: withNewK1(base.url, err.newSecrets[1], total - amountMsat), + callback: base.callback, + amount: total - amountMsat, + verified: false, + mintPubkey: base.mintPubkey, + }, + ], + ) + } + // 'gone': the burn landed - the carried secrets are the only money + partK1 = err.newSecrets[0] + changeK1 = err.newSecrets[1] + rescued = true + } else { + // A classified refusal can still be a LANDED split: the callback is + // a GET and HTTP stacks retry GETs, so the service may have executed + // the first attempt and refused this one as an already-spent input. + // The kit attaches the fresh output secrets to every refusal - probe + // one output before deciding they are worthless. + const carried = newSecretsOf(err) + if (carried.length !== 2) throw err + const outcome = await probeMutationOutput(base.url, carried[0], options) + if (outcome === 'absent') throw err // never landed - a plain refusal + if (outcome === 'unknown') { + // can't tell whether the refusal named a retry - same limbo as + // the ambiguous case above: track the possible outputs, consume + // nothing, stop here + throw new UncertainOutcomeError( + 'The split was refused, but the refusal may have named a retry of a split that already landed - the possible outputs must be tracked unverified alongside the originals until refreshed.', + [ + { + url: withNewK1(base.url, carried[0], amountMsat), + callback: base.callback, + amount: amountMsat, + verified: false, + mintPubkey: base.mintPubkey, + }, + { + url: withNewK1(base.url, carried[1], total - amountMsat), + callback: base.callback, + amount: total - amountMsat, + verified: false, + mintPubkey: base.mintPubkey, + }, + ], + ) + } + // 'live': the split landed and this answer was its retried twin - + // the carried secrets are the only money left + partK1 = carried[0] + changeK1 = carried[1] + rescued = true } - // 'gone': the burn landed - the carried secrets are the only money - partK1 = err.newSecrets[0] - changeK1 = err.newSecrets[1] - rescued = true } const note: NewBearer = { url: withNewK1(base.url, partK1, amountMsat, partSignature), @@ -261,15 +302,38 @@ const mergeAmbiguitySafe = async ( const merged = await mergeNotes(base.callback, k1s, options) return {k1: merged.k1, signature: merged.signature, rescued: false} } catch (err) { - if (!(err instanceof AmbiguousMutationError)) throw err - const outcome = await probeBurnedNote(base.url, options) - if (outcome === 'live') throw err // nothing burned - a plain failure + if (err instanceof AmbiguousMutationError) { + const outcome = await probeBurnedNote(base.url, options) + if (outcome === 'live') throw err // nothing burned - a plain failure + if (outcome === 'unknown') { + throw new UncertainOutcomeError( + 'The merge may have gone through but could not be confirmed - the possible combined note must be tracked unverified alongside the originals until refreshed.', + [ + { + url: withNewK1(base.url, err.newSecrets[0], total), + callback: base.callback, + amount: total, + verified: false, + mintPubkey: base.mintPubkey, + }, + ], + ) + } + // 'gone': the burn landed - the carried secret is the only money left + return {k1: err.newSecrets[0], rescued: true} + } + // same retry-refusal rescue as the split path: the refusal may name a + // merge that already landed - probe the would-be combined note + const carried = newSecretsOf(err) + if (carried.length !== 1) throw err + const outcome = await probeMutationOutput(base.url, carried[0], options) + if (outcome === 'absent') throw err if (outcome === 'unknown') { throw new UncertainOutcomeError( - 'The merge may have gone through but could not be confirmed - the possible combined note must be tracked unverified alongside the originals until refreshed.', + 'The merge was refused, but the refusal may have named a retry of a merge that already landed - the possible combined note must be tracked unverified alongside the originals until refreshed.', [ { - url: withNewK1(base.url, err.newSecrets[0], total), + url: withNewK1(base.url, carried[0], total), callback: base.callback, amount: total, verified: false, @@ -278,7 +342,6 @@ const mergeAmbiguitySafe = async ( ], ) } - // 'gone': the burn landed - the carried secret is the only money left - return {k1: err.newSecrets[0], rescued: true} + return {k1: carried[0], rescued: true} } } diff --git a/src/lnurlcash/ops/mint.ts b/src/lnurlcash/ops/mint.ts index 5abd23b..ccc6044 100644 --- a/src/lnurlcash/ops/mint.ts +++ b/src/lnurlcash/ops/mint.ts @@ -34,6 +34,8 @@ import { lightningAddressUsername, mintAddressUrl, namesMintOutput, + newSecretsOf, + NoteSpentError, probeBurnedNote, requestInvoice, resolveMintInput, @@ -47,7 +49,7 @@ import type {NewBearer} from '../types' import {ceilMsatToSat} from '../units' import type {PollOptions} from './shared' import type {FundOperationOptions} from './shared' -import {PollAbortedError, assertFundOwner, pollUntil, pollVerifyUntilSettled} from './shared' +import {PollAbortedError, assertFundOwner, pollUntil, pollVerifyUntilSettled, probeMutationOutput} from './shared' export type PreparedMint = { invoice: string @@ -361,7 +363,41 @@ export const claimFromPreimage = async ( rotationError = err.message } } else { - rotationError = err instanceof Error ? err.message : String(err) + // A classified refusal can still be a LANDED rotate: the callback is + // a GET and HTTP stacks retry GETs, so the service may have executed + // the first attempt and refused this one as an already-spent input. + // "Already spent" then PROVES the preimage note is burned - the + // carried fresh secret is the only money left, so probe it before + // believing the refusal. + const carried = newSecretsOf(err) + if (carried.length === 1) { + const outcome = await probeMutationOutput(declaredUrl, carried[0], options) + if (outcome === 'live') { + // the rotate landed and this answer was its retried twin + url = withNewK1(declaredUrl, carried[0], noteInfo.maxWithdrawable) + rotated = true + } else if (outcome === 'unknown') { + possibleCopy = { + url: withNewK1(declaredUrl, carried[0], noteInfo.maxWithdrawable), + callback: noteInfo.callback, + amount: noteInfo.maxWithdrawable, + verified: false, + } + if (mintPubkey) possibleCopy.mintPubkey = mintPubkey + // the preimage note itself may be burned - it cannot stay verified + base.verified = false + rotationError = `${err instanceof Error ? err.message : String(err)} The refusal may have named a retry of a rotation that already landed - the possible rotated copy is tracked unverified alongside this one.` + } else if (err instanceof NoteSpentError) { + // a genuine already-spent refusal with no live output: the note + // the service just described is burned - it cannot stay verified + base.verified = false + rotationError = err.message + } else { + rotationError = err instanceof Error ? err.message : String(err) + } + } else { + rotationError = err instanceof Error ? err.message : String(err) + } } } const claimed: ClaimedNote = {note: {...base, url}, rotated} diff --git a/src/lnurlcash/ops/receiveBearer.ts b/src/lnurlcash/ops/receiveBearer.ts index 13cbe87..e3ccc80 100644 --- a/src/lnurlcash/ops/receiveBearer.ts +++ b/src/lnurlcash/ops/receiveBearer.ts @@ -9,6 +9,7 @@ import { NoteSpentError, NoteUnknownError, PendingNoteError, + newSecretsOf, probeBurnedNote, withNewK1, } from 'lnurlcash-kit' @@ -16,7 +17,7 @@ import type {Bearer, NewBearer} from '../types' import {receiveNote, secureReceivedNote} from '../receive' import type {ClaimedNote} from './mint' import type {FundOperationOptions} from './shared' -import {assertFundOwner} from './shared' +import {assertFundOwner, probeMutationOutput} from './shared' // NoteSpentError / NoteUnknownError / PendingNoteError from the service are // definitive and propagate; an unreachable service still yields the note, @@ -32,9 +33,43 @@ export const receiveBearer = async ( } try { assertFundOwner(options) - const rotatedUrl = await secureReceivedNote(note) + const rotatedUrl = await secureReceivedNote(note, options) return {note: {...note, url: rotatedUrl}, rotated: true} } catch (err) { + // A classified rotate refusal can still be a LANDED rotate: the + // callback is a GET and HTTP stacks retry GETs, so the service may + // have executed the first attempt and refused this one as an + // already-spent input. The refusal then proves the sender's copy is + // burned and the carried fresh secret is the only money left - probe + // it before believing the refusal. + const carried = newSecretsOf(err) + if (carried.length === 1 && !(err instanceof AmbiguousMutationError)) { + const outcome = await probeMutationOutput(note.url, carried[0], options) + if (outcome === 'live') { + return { + note: {...note, url: withNewK1(note.url, carried[0], note.amount)}, + rotated: true, + } + } + if (outcome === 'unknown') { + const possibleCopy: NewBearer = { + url: withNewK1(note.url, carried[0], note.amount), + callback: note.callback, + amount: note.amount, + verified: false, + } + if (note.mintPubkey) possibleCopy.mintPubkey = note.mintPubkey + return { + // the sender's copy may be burned - it cannot stay verified + note: {...note, verified: false}, + rotated: false, + possibleCopy, + rotationError: `${err instanceof Error ? err.message : String(err)} The refusal may have named a retry of a rotation that already landed - the possible rotated copy is tracked unverified alongside this one.`, + } + } + // 'absent': the refusal is genuine - fall through to the distinct + // handling below + } // a definitive service state (dead/unknown/locked mid-melt) is not a // rotation failure to warn about - it tells the holder what this note // actually is, so it propagates distinctly diff --git a/src/lnurlcash/receive.ts b/src/lnurlcash/receive.ts index 897cd56..4653be2 100644 --- a/src/lnurlcash/receive.ts +++ b/src/lnurlcash/receive.ts @@ -10,6 +10,7 @@ import { NoteUnknownError, PendingNoteError, } from 'lnurlcash-kit' +import type {LnurlcashOptions} from 'lnurlcash-kit' import type {Bearer, NewBearer} from './types' // shared by Scan and Paste: resolve whatever came in to a note URL, ask the @@ -65,16 +66,21 @@ export const receiveNote = async (input: string, existing: Bearer[]): Promise => { +// speak lnurlcash) - the caller should warn, not fail the receive. The +// error carries the fresh secret (kit's newSecretsOf) so the caller can +// rescue a rotate that landed despite its refusal. +export const secureReceivedNote = async ( + note: { + url: string + callback: string + amount: number + }, + options: LnurlcashOptions = {}, +): Promise => { const k1 = noteK1(note.url) if (!k1 || !note.callback) { throw new Error('Note has no callback to rotate against yet.') } - const result = await rotateNote(note.callback, k1) + const result = await rotateNote(note.callback, k1, options) return withNewK1(note.url, result.k1, note.amount, result.signature) }