diff --git a/package-lock.json b/package-lock.json index be736dc..39e08ac 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 7343415..6422134 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/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.`, 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; 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/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 fd7c4aa..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', { @@ -116,9 +136,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 @@ -163,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/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 +} 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.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/) }) 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 e7f160f..97ce1bf 100644 --- a/src/lnurlcash/ops.test.ts +++ b/src/lnurlcash/ops.test.ts @@ -1,4 +1,6 @@ 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.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.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.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' 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 82f558c..ccc6044 100644 --- a/src/lnurlcash/ops/mint.ts +++ b/src/lnurlcash/ops/mint.ts @@ -1,18 +1,41 @@ // 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, + newSecretsOf, + NoteSpentError, probeBurnedNote, requestInvoice, resolveMintInput, @@ -21,16 +44,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, probeMutationOutput} 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 +118,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 +145,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 +164,126 @@ 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.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.') + } + 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 +303,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 @@ -215,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/ops/shared.ts b/src/lnurlcash/ops/shared.ts index b93fade..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 @@ -76,16 +103,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 +133,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 +146,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, + ) diff --git a/src/lnurlcash/ops/transfer.ts b/src/lnurlcash/ops/transfer.ts index db49837..6141214 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,81 @@ 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.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 = { + 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 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) } 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', ]