diff --git a/e2e/helpers/MintMocker.ts b/e2e/helpers/MintMocker.ts index 6b3acdc..e46a39d 100644 --- a/e2e/helpers/MintMocker.ts +++ b/e2e/helpers/MintMocker.ts @@ -40,8 +40,10 @@ interface MockNoteInfoOptions { } interface MockTargetMintOptions { - // the mint's signing pubkey (66 hex chars), advertised in the payRequest - // and note responses + // the mint's signing pubkey (66 hex chars), advertised on the mint-address + // (/.well-known/lnurlw/) and note-info responses - NEVER the payRequest: + // LUD-25 announces it on the withdraw side only, and a too-generous mock + // once masked a real bug mintPubkey: string; // the invoice this mint hands out (and reports back as settled) - keep it // amount-less (decodeBolt11AmountMsat returns null) so the kit skips the @@ -97,24 +99,38 @@ export class MintMocker { ); } - // A mint that never answers its mint-address discovery endpoint with - // anything usable - prepareMint treats that as "no mint-address support" - // and falls back to the plain LNURL-pay guess. - private async mockNoMintAddress(origin: string): Promise { + // The mint-address discovery endpoint (LUD-25): announces the signing key + // and the node stats a real lnurl-mint advertises. `nodeCapacity` is msat + // under its WIRE name (no suffix) - the kit has to map it onto + // nodeCapacityMsat, which is exactly the 0.1.0 spread bug this exercises. + // The payLink points back at the lnurlp route below, as prepareMint treats + // it as the authoritative place to read the payRequest from. + private async mockMintAddress(options: MockTargetMintOptions, origin: string): Promise { await this.page.route( new RegExp(`^${escapeRegExp(`${origin}/.well-known/lnurlw/`)}`), async (route: Route) => { - await fulfillJson(route, { status: 'ERROR', reason: 'not supported' }); + await fulfillJson(route, { + tag: 'withdrawRequest', + callback: `${origin}${CALLBACK_PATH}`, + minWithdrawable: 1000, + maxWithdrawable: 100_000_000_000, + mintPubkey: options.mintPubkey, + payLink: `${origin}/.well-known/lnurlp/mint`, + nodeCapacity: 500_000_000, + nodeNumChannels: 4, + nodeNumPeers: 6, + }); }, ); } // Everything the target side of a transfer (or a Lightning receive) - // needs: the payRequest at the standard mint@ address, the invoice - // callback, an immediately-settled verify endpoint revealing the - // preimage, and the note info + rotate the claim then performs. + // needs: the mint-address discovery endpoint carrying the mint metadata, + // the payRequest at the standard mint@ address, the invoice callback, an + // immediately-settled verify endpoint revealing the preimage, and the + // note info + rotate the claim then performs. async mockTargetMint(options: MockTargetMintOptions, origin = MINT2_ORIGIN): Promise { - await this.mockNoMintAddress(origin); + await this.mockMintAddress(options, origin); await this.mockNoteInfo( { amountMsat: options.noteAmountMsat, mintPubkey: options.mintPubkey }, origin, @@ -129,7 +145,6 @@ export class MintMocker { minSendable: 1000, maxSendable: 100_000_000_000, withdrawLink: `${origin}${NOTE_PATH}`, - mintPubkey: options.mintPubkey, metadata: options.mintFeeMetadata ?? '[]', }); }, diff --git a/package-lock.json b/package-lock.json index 462aa0e..e859c3b 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": "github:TheCryptoDonkey/lnurlcash-kit#392aeaf6682f23a0e66d28175a9df62802cd1d76", + "lnurlcash-kit": "0.1.1", "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": "github:TheCryptoDonkey/lnurlcash-conformance", + "lnurlcash-conformance": "0.1.1", "postcss": "^8.5.8", "prettier": "^3.8.1", "sass": "^1.102.0", @@ -5961,8 +5961,9 @@ } }, "node_modules/lnurlcash-conformance": { - "version": "0.1.0", - "resolved": "git+ssh://git@github.com/TheCryptoDonkey/lnurlcash-conformance.git#3d63f8ac3f3b8b87f5b490ff4a55c310afe4e5e2", + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/lnurlcash-conformance/-/lnurlcash-conformance-0.1.1.tgz", + "integrity": "sha512-12EXkom7x+tpkk4Pi2zanlMbDpA64hh+b5PKTPoKfahiWPG3GQbNusun1NHKpYjNwyevRISTP9487W+ZqEuEZw==", "dev": true, "license": "MIT", "dependencies": { @@ -5986,9 +5987,9 @@ } }, "node_modules/lnurlcash-kit": { - "version": "0.1.0", - "resolved": "git+ssh://git@github.com/TheCryptoDonkey/lnurlcash-kit.git#392aeaf6682f23a0e66d28175a9df62802cd1d76", - "integrity": "sha512-xgO7ykIBD5SdGV7M6xyhDJcfgbkvlHxmBmXu2gBs/Cew/RAfH7GRZSCdeVexo6vRGDXhX6IjqqqzfFXrYqkM9A==", + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/lnurlcash-kit/-/lnurlcash-kit-0.1.1.tgz", + "integrity": "sha512-O1rak4hcoKIBZdtbCMfQhCS4hp9HducJeJf8nEYxbPXtFm83EyPBqOAwWHYkzJMFKnyTqjJmvjjzfgL3ePQQOw==", "license": "MIT", "dependencies": { "@noble/curves": "^2.3.0", diff --git a/package.json b/package.json index 8b678d6..5a68016 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": "github:TheCryptoDonkey/lnurlcash-kit#392aeaf6682f23a0e66d28175a9df62802cd1d76", + "lnurlcash-kit": "0.1.1", "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": "github:TheCryptoDonkey/lnurlcash-conformance", + "lnurlcash-conformance": "0.1.1", "postcss": "^8.5.8", "prettier": "^3.8.1", "sass": "^1.102.0", diff --git a/src/lnurlcash/mintContract.test.ts b/src/lnurlcash/mintContract.test.ts new file mode 100644 index 0000000..fd7c4aa --- /dev/null +++ b/src/lnurlcash/mintContract.test.ts @@ -0,0 +1,165 @@ +// Wire-contract coverage for the pinned lnurlcash-kit / lnurlcash-conformance +// 0.1.1 artifacts. Two contracts the app's mint discovery relies 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. + +import {afterEach, describe, expect, it} from 'vitest' +import {createMockMint} from 'lnurlcash-conformance/mock-mint' +import {buildNoteUrl, fetchMintAddress} from 'lnurlcash-kit' + +import {claimMintedNote, prepareMint} from './ops' +import {mintAddressCacheInfo} from './trustedMints' + +type Mint = Awaited> + +const mints: Mint[] = [] +const mint = async (options: Parameters[0] = {}): Promise => { + const m = await createMockMint(options) + mints.push(m) + return m +} + +afterEach(async () => { + await Promise.all(mints.splice(0).map((m) => m.close())) +}) + +// paying a mint invoice is what brings its note into existence - the mock +// exposes that through its test hook (settle + credit in one step) +const settleLastInvoice = async (m: Mint): Promise => { + const paymentHash = [...m.state.invoices.keys()].at(-1) + if (!paymentHash) throw new Error('no invoice requested yet') + const res = await fetch(`${m.url}/_test/settle?payment_hash=${paymentHash}`) + if (!res.ok) throw new Error(`settle hook failed: ${res.status}`) + const invoice = m.state.invoices.get(paymentHash) + if (!invoice) throw new Error('settled invoice vanished from the mock') + return invoice.preimage +} + +const MINT_PUBKEY = `02${'ab'.repeat(32)}` + +// a mint-address (LUD-25) wire response exactly as lnurl-mint serves it: +// node stats under their WIRE names - `nodeCapacity` is msat like every +// other amount, named without the suffix on the wire +const mintAddressFixture = { + tag: 'withdrawRequest', + callback: 'https://mint.example/w/cb', + minWithdrawable: 1_000, + maxWithdrawable: 100_000_000, + defaultDescription: 'fixture mint', + payLink: 'https://mint.example/.well-known/lnurlp/mint', + mintPubkey: MINT_PUBKEY, + nodeAlias: 'fixture-mint', + nodeCapacity: 500_000_000, + nodeNumChannels: 4, + nodeNumPeers: 6, +} + +const jsonResponse = (body: unknown): Response => + new Response(JSON.stringify(body), { + status: 200, + headers: {'content-type': 'application/json'}, + }) + +// a fetch that serves fixture bodies by URL prefix and 404s everything else, +// so a test drives the real kit HTTP boundary without any network +const fixtureFetch = (routes: ReadonlyArray): typeof fetch => { + const impl: typeof fetch = (input, _init) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + for (const [prefix, body] of routes) { + if (url.startsWith(prefix)) return Promise.resolve(jsonResponse(body)) + } + return Promise.resolve(new Response('not found', {status: 404})) + } + 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', { + fetch: fixtureFetch([['https://mint.example/', mintAddressFixture]]), + }) + // renamed fields have to be mapped, not spread: the spread carries the + // wire name through and the typed one reads undefined forever + expect(info.nodePubkey).toBe(MINT_PUBKEY) + expect(info.nodeCapacityMsat).toBe(500_000_000) + expect(info.nodeNumChannels).toBe(4) + expect(info.nodeNumPeers).toBe(6) + }) + + it('carries node stats into the cached trusted-mint display metadata', async () => { + const info = await fetchMintAddress('https://mint.example/.well-known/lnurlw/mint', { + fetch: fixtureFetch([['https://mint.example/', mintAddressFixture]]), + }) + const cached = mintAddressCacheInfo(info, 'mint') + expect(cached?.nodeCapacityMsat).toBe(500_000_000) + expect(cached?.nodeNumChannels).toBe(4) + expect(cached?.nodeNumPeers).toBe(6) + }) + + it("surfaces the mock mint's mint-address node stats through prepareMint", async () => { + const m = await mint() + const prepared = await prepareMint(`mint@127.0.0.1:${m.port}`, 21_000) + // the metadata is advertised at the mint-address endpoint itself - + // the payRequest never carried it + expect(prepared.nodeInfo?.nodePubkey).toBe(m.state.pubkey) + expect(prepared.nodeInfo?.nodeCapacityMsat).toBe(500_000_000) + expect(prepared.nodeInfo?.nodeNumChannels).toBe(4) + expect(prepared.nodeInfo?.nodeNumPeers).toBe(6) + const cached = mintAddressCacheInfo(prepared.nodeInfo, prepared.username) + expect(cached?.nodeCapacityMsat).toBe(500_000_000) + }) +}) + +describe('withdraw-link forms', () => { + it('accepts the lnurlw:// withdraw link the conformance mock mint advertises', async () => { + const m = await mint({testHooks: true}) + 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 + const preimage = await settleLastInvoice(m) + const claimed = await claimMintedNote(prepared, { + intervalMs: 10, + intervalCapMs: 50, + maxWaitMs: 5_000, + }) + expect(claimed.rotated).toBe(true) + expect(claimed.note.amount).toBe(21_000) + expect(m.state.noteState(preimage)).toBe('burned') + }) + + it('accepts an HTTPS withdraw link', async () => { + const fetch = fixtureFetch([ + ['https://mint.example/.well-known/lnurlw/mint', mintAddressFixture], + [ + 'https://mint.example/.well-known/lnurlp/mint', + { + tag: 'payRequest', + callback: 'https://mint.example/pay', + minSendable: 1_000, + maxSendable: 100_000_000_000, + withdrawLink: 'https://mint.example/note', + metadata: '[]', + }, + ], + // amount-less invoice: the kit skips its amount cross-check + ['https://mint.example/pay', {pr: 'lnmock1fixture', verify: null}], + ]) + const prepared = await prepareMint('mint@mint.example', 21_000, {fetch}) + expect(prepared.withdrawLink).toBe('https://mint.example/note') + // the mint-address payLink is authoritative - the payRequest came from it + expect(prepared.mintUrl).toBe('https://mint.example/.well-known/lnurlp/mint') + }) + + it('builds the same note URL from both withdraw-link forms', () => { + const k1 = 'ab'.repeat(32) + expect(buildNoteUrl('lnurlw://mint.example/note', k1, 21_000)).toBe( + buildNoteUrl('https://mint.example/note', k1, 21_000), + ) + }) +})