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