From d2fc34ed257f1c2d244b0abe4662ed2a1317a302 Mon Sep 17 00:00:00 2001 From: protom Date: Wed, 19 Aug 2026 22:27:14 +0200 Subject: [PATCH] test: e2e coverage for mint management and inter-mint transfer --- e2e/helpers/MintMocker.ts | 103 +++++++++++++++++++++++++--- e2e/specs/manage-mints.spec.ts | 120 +++++++++++++++++++++++++++++++++ e2e/specs/move-funds.spec.ts | 119 ++++++++++++++++++++++++++++++++ 3 files changed, 331 insertions(+), 11 deletions(-) create mode 100644 e2e/specs/manage-mints.spec.ts create mode 100644 e2e/specs/move-funds.spec.ts diff --git a/e2e/helpers/MintMocker.ts b/e2e/helpers/MintMocker.ts index a16c68f..e644b98 100644 --- a/e2e/helpers/MintMocker.ts +++ b/e2e/helpers/MintMocker.ts @@ -5,13 +5,18 @@ import type { Page, Route } from '@playwright/test'; // never touch the network. Protocol shapes mirror lnurlcash-kit's client: // the informational GET on the note URL (fetchNoteInfo) expects an LUD-03 // withdrawRequest echoing the queried k1, or the LNURL ERROR envelope; the -// rotation GET on the callback (rotateNote) expects {status: "OK"}. +// rotation GET on the callback (rotateNote) expects {status: "OK"}. The same +// callback shape also answers a melt (meltNote sends k1 + pr to the same +// callback and only requires status "OK"). +// +// Every method takes an optional origin, so a spec can stand up a SECOND +// mint (e.g. MINT2_ORIGIN) for inter-mint transfers. export const MINT_ORIGIN = 'https://mint.test'; +export const MINT2_ORIGIN = 'https://mint2.test'; export const NOTE_PATH = '/note'; export const CALLBACK_PATH = '/callback'; -const escapeRegExp = (value: string): string => - value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const fulfillJson = async (route: Route, body: unknown): Promise => { await route.fulfill({ @@ -30,6 +35,23 @@ interface MockNoteInfoOptions { // when set, the mint answers the ERROR envelope with this reason instead // (a reason matching /spent/i surfaces the "already been spent" UI) spentReason?: string; + // the mint's signing pubkey, advertised as mintPubkey in responses + mintPubkey?: string; +} + +interface MockTargetMintOptions { + // the mint's signing pubkey (66 hex chars), advertised in the payRequest + // and note responses + 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 + // amount cross-check + invoice: string; + // the payment preimage a settled verify reveals - 64 hex chars (it IS the + // claimed note's secret) + preimage: string; + // value of the note a claim mints, in msat + noteAmountMsat: number; } export class MintMocker { @@ -38,9 +60,9 @@ export class MintMocker { // The note's informational GET: a spec-shaped withdrawRequest, or the // ERROR envelope for a note the mint considers spent. The kit validates // that the service echoes back the queried k1, so read it off the request. - async mockNoteInfo(options: MockNoteInfoOptions): Promise { + async mockNoteInfo(options: MockNoteInfoOptions, origin = MINT_ORIGIN): Promise { await this.page.route( - new RegExp(`^${escapeRegExp(MINT_ORIGIN + NOTE_PATH)}\\?`), + new RegExp(`^${escapeRegExp(origin + NOTE_PATH)}\\?`), async (route: Route) => { if (options.spentReason !== undefined) { await fulfillJson(route, { status: 'ERROR', reason: options.spentReason }); @@ -49,25 +71,84 @@ export class MintMocker { const k1 = new URL(route.request().url()).searchParams.get('k1') ?? ''; await fulfillJson(route, { tag: 'withdrawRequest', - callback: `${MINT_ORIGIN}${CALLBACK_PATH}`, + callback: `${origin}${CALLBACK_PATH}`, k1, minWithdrawable: options.amountMsat ?? 0, maxWithdrawable: options.amountMsat ?? 0, defaultDescription: 'mock mint note', + ...(options.mintPubkey ? { mintPubkey: options.mintPubkey } : {}), }); }, ); } - // The rotation callback GET (k1 + h params): confirm with the OK envelope. - // No sig is returned - a plain LUD-03-style service that speaks rotate but - // does not sign notes. - async mockRotateOk(): Promise { + // The mutating callback GET: rotate (k1 + h params) and melt (k1 + pr + // params) both expect a plain {status: "OK"} confirmation. No sig is + // returned - a plain LUD-03-style service that does not sign notes. + async mockRotateOk(origin = MINT_ORIGIN): Promise { await this.page.route( - new RegExp(`^${escapeRegExp(MINT_ORIGIN + CALLBACK_PATH)}\\?`), + new RegExp(`^${escapeRegExp(origin + CALLBACK_PATH)}\\?`), async (route: Route) => { await fulfillJson(route, { status: 'OK' }); }, ); } + + // 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 { + await this.page.route( + new RegExp(`^${escapeRegExp(`${origin}/.well-known/lnurlw/`)}`), + async (route: Route) => { + await fulfillJson(route, { status: 'ERROR', reason: 'not supported' }); + }, + ); + } + + // 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. + async mockTargetMint(options: MockTargetMintOptions, origin = MINT2_ORIGIN): Promise { + await this.mockNoMintAddress(origin); + await this.mockNoteInfo( + { amountMsat: options.noteAmountMsat, mintPubkey: options.mintPubkey }, + origin, + ); + await this.mockRotateOk(origin); + await this.page.route( + new RegExp(`^${escapeRegExp(`${origin}/.well-known/lnurlp/`)}`), + async (route: Route) => { + await fulfillJson(route, { + tag: 'payRequest', + callback: `${origin}/pay`, + minSendable: 1000, + maxSendable: 100_000_000_000, + withdrawLink: `${origin}${NOTE_PATH}`, + mintPubkey: options.mintPubkey, + metadata: '[]', + }); + }, + ); + await this.page.route( + new RegExp(`^${escapeRegExp(`${origin}/pay`)}\\?`), + async (route: Route) => { + await fulfillJson(route, { + pr: options.invoice, + verify: `${origin}/verify`, + }); + }, + ); + await this.page.route( + new RegExp(`^${escapeRegExp(`${origin}/verify`)}`), + async (route: Route) => { + await fulfillJson(route, { + settled: true, + preimage: options.preimage, + pr: options.invoice, + }); + }, + ); + } } diff --git a/e2e/specs/manage-mints.spec.ts b/e2e/specs/manage-mints.spec.ts new file mode 100644 index 0000000..6dd7df0 --- /dev/null +++ b/e2e/specs/manage-mints.spec.ts @@ -0,0 +1,120 @@ +import { test, expect } from '../fixtures'; +import type { Page } from '@playwright/test'; +import { buildNoteUrl, defaultRandomSecret } from 'lnurlcash-kit'; +import { MINT_ORIGIN, NOTE_PATH } from '../helpers/MintMocker'; +import { createFreshWallet } from '../helpers/wallet'; + +// a 33-byte compressed secp256k1 pubkey, hex - what the trusted-mint +// registry accepts +const MINT_PUBKEY = `02${'ab'.repeat(32)}`; + +// the payRequest discovery behind a one-tap suggestion: the app resolves +// "@mint.600.wtf" to https://mint.600.wtf/.well-known/lnurlp/mint and reads +// the mint's signing key out of the response +const mockSuggestionMint = async (page: Page): Promise => { + const fulfill = async (route: import('@playwright/test').Route, body: unknown) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + headers: { 'Access-Control-Allow-Origin': '*' }, + body: JSON.stringify(body), + }); + }; + // no mint-address discovery support - the app falls back to the LNURL-pay + await page.route(/^https:\/\/mint\.600\.wtf\/\.well-known\/lnurlw\//, (route) => + fulfill(route, { status: 'ERROR', reason: 'not supported' }), + ); + await page.route(/^https:\/\/mint\.600\.wtf\/\.well-known\/lnurlp\/mint/, (route) => + fulfill(route, { + tag: 'payRequest', + callback: 'https://mint.600.wtf/pay', + minSendable: 1000, + maxSendable: 100_000_000_000, + withdrawLink: 'https://mint.600.wtf/note', + mintPubkey: MINT_PUBKEY, + metadata: '[]', + }), + ); +}; + +test.describe('Manage mints', () => { + test('trust a suggested mint, set it default, remove it', async ({ page }) => { + await mockSuggestionMint(page); + await createFreshWallet(page); + + // Settings -> Mints group -> Manage mints + await page.getByRole('button', { name: 'Settings' }).click(); + await page.getByRole('button', { name: 'Manage mints' }).click(); + await expect(page).toHaveURL(/#\/settings\/mints$/); + + // empty state, and the public-mint suggestion is offered + await expect(page.getByText('No mints yet', { exact: false })).toBeVisible(); + const suggestion = page.getByRole('button', { name: '@mint.600.wtf' }); + await expect(suggestion).toBeVisible(); + await suggestion.click(); + + // the mint lands in the trusted list with its key fingerprint + const mintsList = page.locator('.q-list', { hasText: 'Your mints' }); + await expect(mintsList.getByText('mint.600.wtf')).toBeVisible(); + await expect(mintsList.getByText(`Key ${MINT_PUBKEY.slice(0, 10)}`)).toBeVisible(); + await expect(mintsList.getByText('0 sats held here')).toBeVisible(); + // the trusted suggestion disappears from the suggestion row + await expect(page.getByRole('button', { name: '@mint.600.wtf' })).toHaveCount(0); + + // set as default, then clear + await mintsList.getByRole('button', { name: 'Set default' }).click(); + await expect(mintsList.locator('.q-badge', { hasText: 'Default' })).toBeVisible(); + await mintsList.getByRole('button', { name: 'Clear default' }).click(); + await expect(mintsList.locator('.q-badge')).toHaveCount(0); + await mintsList.getByRole('button', { name: 'Set default' }).click(); + await expect(mintsList.locator('.q-badge', { hasText: 'Default' })).toBeVisible(); + + // remove: confirm dialog, then back to the empty state + await mintsList.getByRole('button', { name: 'Remove' }).click(); + const confirm = page.locator('.q-dialog', { hasText: 'Remove mint' }); + await confirm.getByRole('button', { name: 'Remove' }).click(); + await expect(mintsList.getByText('mint.600.wtf')).toHaveCount(0); + await expect(page.getByText('No mints yet', { exact: false })).toBeVisible(); + }); + + test('a mint with held notes cannot be removed', async ({ page, mint }) => { + // hold a 21-sat note from the mock mint - holding funds locks the mint + // against removal + await mint.mockNoteInfo({ amountMsat: 21_000, mintPubkey: MINT_PUBKEY }); + await mint.mockRotateOk(); + await createFreshWallet(page); + + await page.getByRole('button', { name: 'Receive' }).click(); + const chooser = page.locator('.q-dialog', { hasText: 'Paste or scan a note' }); + await chooser.getByRole('button', { name: 'Bearer note' }).click(); + const dialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' }); + await dialog + .locator('textarea') + .fill(buildNoteUrl(`${MINT_ORIGIN}${NOTE_PATH}`, defaultRandomSecret(), 21_000)); + await dialog.getByRole('button', { name: 'Receive', exact: true }).click(); + await expect(dialog.getByText('Received 21 sats')).toBeVisible(); + // the note advertises a mint key, so the first-contact trust prompt + // opens as its own dialog - trust it (the mint stays locked either way, + // since we hold its note) + await page + .locator('.q-dialog', { hasText: 'New mint' }) + .getByRole('button', { name: 'Trust this mint' }) + .click(); + await dialog.getByRole('button', { name: 'Done' }).click(); + + await page.goto('/#/settings/mints'); + const mintsList = page.locator('.q-list', { hasText: 'Your mints' }); + await expect(mintsList.getByText('mint.test')).toBeVisible(); + await expect(mintsList.getByText('21 sats held here')).toBeVisible(); + + await mintsList.getByRole('button', { name: 'Remove' }).click(); + const confirm = page.locator('.q-dialog', { hasText: 'Remove mint' }); + await confirm.getByRole('button', { name: 'Remove' }).click(); + + // the locked-mint error surfaces as a friendly banner, entry stays + await expect( + page.locator('.q-banner', { hasText: "can't be removed while you hold notes" }), + ).toBeVisible(); + await expect(mintsList.getByText('mint.test')).toBeVisible(); + }); +}); diff --git a/e2e/specs/move-funds.spec.ts b/e2e/specs/move-funds.spec.ts new file mode 100644 index 0000000..966e3bc --- /dev/null +++ b/e2e/specs/move-funds.spec.ts @@ -0,0 +1,119 @@ +import { test, expect } from '../fixtures'; +import type { Page } from '@playwright/test'; +import { buildNoteUrl, defaultRandomSecret } from 'lnurlcash-kit'; +import { MINT_ORIGIN, MINT2_ORIGIN, NOTE_PATH } from '../helpers/MintMocker'; +import { createFreshWallet } from '../helpers/wallet'; + +const AMOUNT_MSAT = 50_000; // 50 sats +const MINT_PUBKEY = `02${'ab'.repeat(32)}`; +const TARGET_PUBKEY = `03${'cd'.repeat(32)}`; +// 64 hex chars - a valid preimage (it becomes the claimed note's secret) +const PREIMAGE = 'ef'.repeat(32); +// amount-less by decodeBolt11AmountMsat, so the kit skips its invoice +// amount cross-check against the requested value +const INVOICE = 'lnmock1transfer'; + +// give the wallet a verified, spendable 50-sat note at the source mint +const fundSourceMint = async (page: Page, mint: import('../helpers/MintMocker').MintMocker) => { + await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT, mintPubkey: MINT_PUBKEY }); + await mint.mockRotateOk(); + await createFreshWallet(page); + + await page.getByRole('button', { name: 'Receive' }).click(); + const chooser = page.locator('.q-dialog', { hasText: 'Paste or scan a note' }); + await chooser.getByRole('button', { name: 'Bearer note' }).click(); + const dialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' }); + await dialog + .locator('textarea') + .fill(buildNoteUrl(`${MINT_ORIGIN}${NOTE_PATH}`, defaultRandomSecret(), AMOUNT_MSAT)); + await dialog.getByRole('button', { name: 'Receive', exact: true }).click(); + await expect(dialog.getByText('Received 50 sats')).toBeVisible(); + // the note advertises a mint key, so the first-contact trust prompt opens + // as its own dialog - trust it (that also locks the mint) + await page + .locator('.q-dialog', { hasText: 'New mint' }) + .getByRole('button', { name: 'Trust this mint' }) + .click(); + await dialog.getByRole('button', { name: 'Done' }).click(); +}; + +// pick an option from a Quasar select identified by its label +const pickOption = async (page: Page, label: string, option: string) => { + await page.locator('.q-field', { hasText: label }).click(); + await page.getByRole('option', { name: option }).click(); +}; + +test.describe('Move funds', () => { + test('form renders and blocks bad amounts', async ({ page, mint }) => { + await fundSourceMint(page, mint); + + await page.goto('/#/settings/move'); + + // the source select lists only mints with spendable balance + await pickOption(page, 'From mint', 'mint.test - 50 sats available'); + await pickOption(page, 'To mint', 'Another mint…'); + await page.getByLabel('Target mint address').fill('@mint2.test'); + + // no amount: Continue stays disabled + const continueBtn = page.getByRole('button', { name: 'Continue' }); + await expect(continueBtn).toBeDisabled(); + + // over the source balance: blocked with an inline error + await page.getByLabel('Amount').fill('51'); + await continueBtn.click(); + await expect( + page.locator('.q-banner', { hasText: 'more than the 50 sats spendable at mint.test' }), + ).toBeVisible(); + + // the Max helper fills the source balance and passes validation + await page.getByRole('button', { name: 'Max' }).click(); + await expect(page.getByLabel('Amount')).toHaveValue('50'); + await continueBtn.click(); + await expect(page.getByText('mint.test')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Move now' })).toBeVisible(); + }); + + test('a two-mint transfer moves the balance', async ({ page, mint }) => { + await fundSourceMint(page, mint); + // the target mint: hands out the invoice, reports it settled with the + // preimage, and answers the claim's note info + rotation + await mint.mockTargetMint( + { + mintPubkey: TARGET_PUBKEY, + invoice: INVOICE, + preimage: PREIMAGE, + noteAmountMsat: AMOUNT_MSAT, + }, + MINT2_ORIGIN, + ); + + await page.goto('/#/settings/move'); + await pickOption(page, 'From mint', 'mint.test - 50 sats available'); + await pickOption(page, 'To mint', 'Another mint…'); + await page.getByLabel('Target mint address').fill('@mint2.test'); + await page.getByLabel('Amount').fill('50'); + await page.getByRole('button', { name: 'Continue' }).click(); + await page.getByRole('button', { name: 'Move now' }).click(); + + // settled: success screen with the fee summary (scoped to the page - + // a toast repeats the "Moved" message) + await expect(page.locator('.q-page').getByText('Moved 50 sats')).toBeVisible(); + await expect(page.getByText('No fees were charged.')).toBeVisible(); + await page.getByRole('button', { name: 'Done' }).click(); + + // the balance survived the move, now held at the target mint + await page.goto('/#/'); + await expect(page.locator('.balance-card .text-h2')).toHaveText('50'); + await page.goto('/#/settings/mints'); + const mintsList = page.locator('.q-list', { hasText: 'Your mints' }); + await expect(mintsList.getByText('mint2.test')).toBeVisible(); + await expect(mintsList.getByText('50 sats held here')).toBeVisible(); + }); + + test('a locked wallet is sent back to the main page', async ({ page }) => { + // no wallet on this device at all: the guard redirects to / + await page.goto('/#/settings/move'); + await expect(page).toHaveURL(/#\/$/); + await expect(page.getByText('Welcome to sattle')).toBeVisible(); + }); +});