From 644e64ee10bba66609acf5161e176770b5bf5ee1 Mon Sep 17 00:00:00 2001 From: protom Date: Mon, 7 Sep 2026 10:43:30 +0200 Subject: [PATCH] feat: add note management and activity history --- e2e/specs/activity-log.spec.ts | 135 ++++++++++ e2e/specs/notes.spec.ts | 156 ++++++++++++ src/components/HistoryList.vue | 18 +- src/composables/useMoveFundsPage.ts | 28 ++- src/composables/useNotesPage.test.ts | 131 ++++++++++ src/composables/useNotesPage.ts | 312 ++++++++++++++++++++++++ src/lnurlcash/ops.transfer.cases.ts | 23 ++ src/lnurlcash/storage.test.ts | 49 ++++ src/lnurlcash/storage/activityLog.ts | 2 + src/lnurlcash/storage/bearers.ts | 2 + src/lnurlcash/types.ts | 3 + src/pages/ActivityLogPage.vue | 62 +++++ src/pages/IndexPage.vue | 11 +- src/pages/MoveFundsPage.vue | 2 + src/pages/NotesPage.vue | 273 +++++++++++++++++++++ src/pages/SettingsPage.vue | 9 +- src/router/routes.ts | 8 + src/stores/walletFunds.recovery.test.ts | 89 +++++++ src/stores/walletFunds.ts | 17 +- 19 files changed, 1315 insertions(+), 15 deletions(-) create mode 100644 e2e/specs/activity-log.spec.ts create mode 100644 e2e/specs/notes.spec.ts create mode 100644 src/composables/useNotesPage.test.ts create mode 100644 src/composables/useNotesPage.ts create mode 100644 src/pages/ActivityLogPage.vue create mode 100644 src/pages/NotesPage.vue diff --git a/e2e/specs/activity-log.spec.ts b/e2e/specs/activity-log.spec.ts new file mode 100644 index 0000000..e8f0710 --- /dev/null +++ b/e2e/specs/activity-log.spec.ts @@ -0,0 +1,135 @@ +import { test, expect } from '../fixtures'; +import type { Page } from '@playwright/test'; +import { sha256 } from '@noble/hashes/sha2.js'; +import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js'; +import { createFreshWallet, fundMockMintWallet } from '../helpers/wallet'; +import type { ActivityKind } from '../../src/lnurlcash/storage/activityLog'; + +// Settings chrome (version footer, no About entry) and the activity +// history: the home-page preview link and the full log's pagination. + +type SeededEvent = { kind: ActivityKind; message: string; createdAt: number }; + +// Write encrypted activity records straight into localStorage, built with +// the app's own wrap (keys.ts: sha256(linking key + context) -> AES-GCM). +// The activity store only reads the log on unlock, so callers reload after +// seeding. +const seedActivityEvents = async (page: Page, events: SeededEvent[]): Promise => { + const linkingKeyHex = await page.evaluate(() => { + const saved: unknown = JSON.parse(localStorage.getItem('sattle_wallet_material_v2') ?? '{}'); + if ( + typeof saved !== 'object' || + saved === null || + !('enc' in saved) || + saved.enc !== false || + !('value' in saved) || + typeof saved.value !== 'string' + ) { + throw new Error('expected a plaintext saved wallet material record'); + } + const material: unknown = JSON.parse(saved.value); + if ( + typeof material !== 'object' || + material === null || + !('linkingKeyHex' in material) || + typeof material.linkingKeyHex !== 'string' + ) { + throw new Error('expected the saved material to carry its linking key'); + } + return material.linkingKeyHex; + }); + const material = sha256( + new Uint8Array([ + ...hexToBytes(linkingKeyHex), + ...utf8ToBytes('lnurlcash-bearer-encryption-v1'), + ]), + ); + const aesKey = await crypto.subtle.importKey('raw', material, 'AES-GCM', false, ['encrypt']); + const records = []; + for (const event of events) { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = new Uint8Array( + await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + aesKey, + utf8ToBytes(JSON.stringify(event)), + ), + ); + records.push({ + id: bytesToHex(crypto.getRandomValues(new Uint8Array(8))), + iv: bytesToHex(iv), + ciphertext: bytesToHex(ciphertext), + }); + } + await page.evaluate( + (json) => localStorage.setItem('sattle_activity', json), + JSON.stringify(records), + ); +}; + +test.describe('Settings', () => { + test('shows the app version and no About entry', async ({ page }) => { + await createFreshWallet(page); + + await page.goto('/#/settings'); + const settings = page.locator('.q-page'); + await expect(settings.getByText(/^sattle v\d+\.\d+\.\d+/)).toBeVisible(); + await expect(settings.locator('.q-item', { hasText: 'About' })).toHaveCount(0); + // the advanced entries are live routes now + await expect(settings.locator('.q-item', { hasText: 'Notes' })).toBeVisible(); + await expect(settings.locator('.q-item', { hasText: 'Activity log' })).toBeVisible(); + }); +}); + +test.describe('Activity history', () => { + test('the home history preview links to the full activity log', async ({ page, mint }) => { + await fundMockMintWallet(page, mint, 21_000); + + // the preview sits inside the History expansion on the main page + const history = page.locator('.q-expansion-item', { hasText: 'History' }); + await history.getByText('History', { exact: true }).click(); + await expect( + history.locator('.history-list').getByText('Received 21 sats from mint.test.'), + ).toBeVisible(); + + await history.getByRole('button', { name: 'View all activity' }).click(); + await expect(page).toHaveURL(/#\/settings\/activity$/); + await expect( + page.locator('.q-page').getByText('Received 21 sats from mint.test.'), + ).toBeVisible(); + }); + + test('paginates the activity log', async ({ page }) => { + await createFreshWallet(page); + const base = Date.now(); + const kinds: ActivityKind[] = ['mint', 'receive', 'refresh']; + await seedActivityEvents( + page, + Array.from({ length: 25 }, (_, index) => ({ + kind: kinds[index % kinds.length] ?? 'mint', + message: `Seeded activity ${String(index).padStart(2, '0')}`, + createdAt: base - index * 60_000, + })), + ); + + // the log is decrypted on unlock - reload into the auto-unlock + await page.reload(); + await page.goto('/#/settings/activity'); + await expect(page.locator('.q-page').getByText('Activity log')).toBeVisible(); + + // page 1 shows the 20 newest events + const rows = page.locator('.q-page .history-list .q-item'); + await expect(rows).toHaveCount(20); + await expect(rows.first()).toContainText('Seeded activity 00'); + await expect(rows.last()).toContainText('Seeded activity 19'); + + const pagination = page.locator('.q-pagination'); + await expect(pagination).toBeVisible(); + await pagination.getByRole('button', { name: '2', exact: true }).click(); + + // page 2 shows the remaining five, still newest-first + await expect(rows).toHaveCount(5); + await expect(rows.first()).toContainText('Seeded activity 20'); + await expect(rows.last()).toContainText('Seeded activity 24'); + }); +}); diff --git a/e2e/specs/notes.spec.ts b/e2e/specs/notes.spec.ts new file mode 100644 index 0000000..a2fab64 --- /dev/null +++ b/e2e/specs/notes.spec.ts @@ -0,0 +1,156 @@ +import { test, expect } from '../fixtures'; +import type { Page } from '@playwright/test'; +import { fundMockMintWallet } from '../helpers/wallet'; + +// The Notes page (Settings -> Advanced -> Notes): per-note detail dialog, +// split/combine mutations against the mock mint, check & refresh rotation, +// and the hand-off into a selected-note move. + +const AMOUNT_MSAT = 21_000; // 21 sats + +// 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(); +}; + +// split the wallet's 21-sat note into 8 + 13 sats through the notes UI +const splitInto8And13 = async (page: Page) => { + await page.goto('/#/settings/notes'); + await page.locator('.q-page .q-item', { hasText: '21 sats' }).click(); + const dialog = page.locator('.q-dialog .note-detail'); + await dialog.getByRole('button', { name: 'Split' }).click(); + const splitDialog = page.locator('.q-dialog .split-dialog'); + await splitDialog.getByLabel('New note amount').fill('8'); + await splitDialog.getByRole('button', { name: 'Split' }).click(); + await expect(page.locator('.q-notification').getByText('Note split.')).toBeVisible(); +}; + +test.describe('Notes page', () => { + test('shows note details and check & refresh rotates to a fresh secret', async ({ + page, + mint, + }) => { + await fundMockMintWallet(page, mint, AMOUNT_MSAT); + + await page.goto('/#/settings/notes'); + const list = page.locator('.q-page .q-list'); + await expect(list.getByText('21 sats')).toBeVisible(); + + // the detail dialog shows the note's identity, status and actions + await list.getByText('21 sats').click(); + const dialog = page.locator('.q-dialog .note-detail'); + await expect(dialog.getByText('mint.test')).toBeVisible(); + await expect(dialog.getByText('Note ID')).toBeVisible(); + await expect(dialog.getByText('Mint signing key')).toBeVisible(); + await expect(dialog.locator('.q-badge', { hasText: 'Valid locally' })).toBeVisible(); + await expect(dialog.getByRole('button', { name: 'Split' })).toBeEnabled(); + await expect(dialog.getByRole('button', { name: 'Move' })).toBeEnabled(); + + // A refresh is a secret rotation of the same logical note, so private + // local metadata must follow the active replacement. + await dialog.getByLabel('Private label').fill('Emergency cash'); + await dialog.getByRole('button', { name: 'Save label' }).click(); + await expect(page.locator('.q-notification').getByText('Label saved.')).toBeVisible(); + + // the check verifies the note mint-side and rotates it to a fresh + // secret; completion is signalled by the toast + await dialog.getByRole('button', { name: 'Check & refresh' }).click(); + await expect( + page.locator('.q-notification').getByText('Note is valid and has a fresh secret.'), + ).toBeVisible(); + + // the checked copy is retired and the rotated note stands valid - the + // wallet still holds exactly 21 spendable sats + await page.goto('/#/settings/notes'); + await expect(page.locator('.q-page .q-item', { hasText: '21 sats' })).toHaveCount(2); + await expect(page.locator('.q-page .q-badge', { hasText: 'Valid locally' })).toHaveCount(1); + await expect(page.locator('.q-page .q-badge', { hasText: 'Spent' })).toHaveCount(1); + await expect( + page.locator('.q-page .q-item', { hasText: 'Emergency cash' }).getByText('Valid locally'), + ).toBeVisible(); + + // and the refresh is on the activity log + await page.goto('/#/settings/activity'); + await expect( + page.locator('.q-page').getByText('Checked and refreshed a 21 sat note from mint.test.'), + ).toBeVisible(); + }); + + test('splits a note and combines the pieces back once the change is refreshed', async ({ + page, + mint, + }) => { + await fundMockMintWallet(page, mint, AMOUNT_MSAT); + await splitInto8And13(page); + + // the split retires the original and mints the 8-sat target verified; + // the 13-sat change stays unverified (an upper bound until refreshed), + // so it can neither be selected nor combined yet + const list = page.locator('.q-page .q-list'); + await expect(list.locator('.q-item', { hasText: '8 sats' })).toHaveCount(1); + await expect(list.locator('.q-item', { hasText: '13 sats' })).toHaveCount(1); + await expect(list.locator('.q-item', { hasText: '13 sats' })).toContainText('Unverified'); + await expect(page.locator('.q-checkbox[aria-label="Select 13 sat note"]')).toBeDisabled(); + + // refreshing the change note verifies and rotates it + await list.locator('.q-item', { hasText: '13 sats' }).click(); + const dialog = page.locator('.q-dialog .note-detail'); + await dialog.getByRole('button', { name: 'Check & refresh' }).click(); + await expect( + page.locator('.q-notification').getByText('Note is valid and has a fresh secret.'), + ).toBeVisible(); + + // now both pieces can be selected and combined back (the detail dialog + // stays open on the retired copy - dismiss it to reach the list) + await page.keyboard.press('Escape'); + await expect(page.locator('.note-detail')).toHaveCount(0); + await page.locator('.q-checkbox[aria-label="Select 8 sat note"]').click(); + await page.locator('.q-checkbox[aria-label="Select 13 sat note"]:not(.disabled)').click(); + const mergeBar = page.locator('.merge-bar'); + await expect(mergeBar.getByText('2 selected')).toBeVisible(); + await mergeBar.getByRole('button', { name: 'Combine' }).click(); + + // one valid 21-sat note again; every intermediate copy shows as spent + await expect(page.locator('.q-notification').getByText('Notes combined.')).toBeVisible(); + await expect(page.locator('.q-page .q-badge', { hasText: 'Valid locally' })).toHaveCount(1); + await expect(page.locator('.q-page .q-badge', { hasText: 'Spent' })).toHaveCount(4); + + // no sats were created or destroyed along the way + await page.goto('/#/'); + await expect(page.locator('.balance-card .text-h2')).toHaveText('21'); + }); + + test('moving from a note prefills its amount and locks the source to that note', async ({ + page, + mint, + }) => { + await fundMockMintWallet(page, mint, AMOUNT_MSAT); + await splitInto8And13(page); + + // move the 8-sat piece: the move form opens with the note's mint and + // amount prefilled, and the source select is locked + await page.locator('.q-page .q-item', { hasText: '8 sats' }).click(); + const dialog = page.locator('.q-dialog .note-detail'); + await dialog.getByRole('button', { name: 'Move' }).click(); + + await expect(page).toHaveURL(/#\/settings\/move\?noteId=/); + const sourceField = page.locator('.q-field', { hasText: 'From mint' }); + await expect(sourceField).toHaveClass(/q-field--disabled/); + await expect(sourceField).toContainText('mint.test - 8 sats available'); + await expect(page.getByLabel('Amount')).toHaveValue('8'); + + // Max tops up to the selected note alone, not the wallet's 21 sats + await page.getByRole('button', { name: 'Max' }).click(); + await expect(page.getByLabel('Amount')).toHaveValue('8'); + + // and asking for more than the locked note is refused inline + await pickOption(page, 'To mint', 'Another mint…'); + await page.getByLabel('Target mint address').fill('@mint2.test'); + await page.getByLabel('Amount').fill('9'); + await page.getByRole('button', { name: 'Continue' }).click(); + await expect( + page.locator('.q-banner', { hasText: 'more than the 8 sats spendable at mint.test' }), + ).toBeVisible(); + }); +}); diff --git a/src/components/HistoryList.vue b/src/components/HistoryList.vue index 6de1da5..81297ef 100644 --- a/src/components/HistoryList.vue +++ b/src/components/HistoryList.vue @@ -1,19 +1,28 @@ diff --git a/src/pages/IndexPage.vue b/src/pages/IndexPage.vue index 78e423f..8c58d15 100644 --- a/src/pages/IndexPage.vue +++ b/src/pages/IndexPage.vue @@ -100,7 +100,16 @@ label="History" header-class="text-primary text-body2" > - + + + diff --git a/src/pages/MoveFundsPage.vue b/src/pages/MoveFundsPage.vue index efbb062..eb23c97 100644 --- a/src/pages/MoveFundsPage.vue +++ b/src/pages/MoveFundsPage.vue @@ -33,6 +33,7 @@ outlined color="primary" label="From mint" + :disable="!!selectedNote" class="q-mb-md" /> @@ -250,6 +251,7 @@ const { setMax, sourceOptions, sourceServer, + selectedNote, stage, step, targetChoice, diff --git a/src/pages/NotesPage.vue b/src/pages/NotesPage.vue new file mode 100644 index 0000000..29e2901 --- /dev/null +++ b/src/pages/NotesPage.vue @@ -0,0 +1,273 @@ + + + + + diff --git a/src/pages/SettingsPage.vue b/src/pages/SettingsPage.vue index 1d9b378..14c2b5f 100644 --- a/src/pages/SettingsPage.vue +++ b/src/pages/SettingsPage.vue @@ -39,13 +39,17 @@ + +
sattle v{{ version }}
diff --git a/src/router/routes.ts b/src/router/routes.ts index 6e08555..799e42b 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -27,6 +27,14 @@ const routes: RouteRecordRaw[] = [ path: 'settings/nwc', component: () => import('@/pages/NwcPage.vue'), }, + { + path: 'settings/notes', + component: () => import('@/pages/NotesPage.vue'), + }, + { + path: 'settings/activity', + component: () => import('@/pages/ActivityLogPage.vue'), + }, ], }, { diff --git a/src/stores/walletFunds.recovery.test.ts b/src/stores/walletFunds.recovery.test.ts index 8d8b894..775f4cb 100644 --- a/src/stores/walletFunds.recovery.test.ts +++ b/src/stores/walletFunds.recovery.test.ts @@ -427,3 +427,92 @@ describe('pending mint recovery', () => { expect(readFundsDocument().nextByHost).toEqual({ 'mint.example': 2 }); }); }); + +describe('pending note-refresh recovery', () => { + // a held-note refresh stages its future rotated note with + // pendingMint.refreshSourceBearerId pointing at the still-live source - + // the source is never locked at staging time, so recovery owes it nothing + // unless the rotate provably landed + const refreshStage = (instance: Mint, rotationSecret: string, sourceId: string): NewBearer => ({ + url: buildNoteUrl(`${instance.url}/w`, rotationSecret, 21_000), + callback: `${instance.url}/w/cb`, + // The URL retains the expected amount for recovery, but an unconfirmed + // refresh output must not contribute to the wallet balance. + amount: 0, + verified: false, + label: 'kept label', + pendingMint: { refreshSourceBearerId: sourceId, sourceRecoverySecret: '00'.repeat(32) }, + }); + + it('drops the staged refresh when the rotate never landed, keeping the held source live', async () => { + const instance = await mint(); + const wallet = useWalletStore(); + await wallet.create(); + const sourceSecret = '7a'.repeat(32); + const rotationSecret = '7b'.repeat(32); + instance.state.creditNote(sourceSecret, 21_000); + const [source] = await wallet.addBearers( + [liveNote(instance, sourceSecret)], + wallet.captureOwnerFence(), + ); + if (!source) throw new Error('Expected a source bearer.'); + // nothing was ever credited at the staged secret: the rotate never + // reached the mint + const [staged] = await wallet.addBearers( + [refreshStage(instance, rotationSecret, source.id)], + wallet.captureOwnerFence(), + ); + if (!staged) throw new Error('Expected a staged bearer.'); + expect(wallet.balanceMsat).toBe(21_000); + + await wallet.recoverPendingMints(wallet.captureOwnerFence()); + + // the disposable stage is gone and the held source is untouched - a + // kept stage would double-count the same sats + expect(wallet.bearers.some((bearer) => bearer.id === staged.id)).toBe(false); + const held = wallet.bearers.find((bearer) => bearer.id === source.id); + expect(held).toMatchObject({ amount: 21_000, verified: true }); + expect(held?.spent).toBeUndefined(); + expect(held?.url).toContain(sourceSecret); + expect(wallet.balanceMsat).toBe(21_000); + expect(instance.state.noteState(sourceSecret)).toBe('outstanding'); + }); + + it('retires the held source in the same write that finalizes a landed refresh rotate', async () => { + const storage = stubLocalStorage(); + const instance = await mint(); + const wallet = useWalletStore(); + await wallet.create(); + const sourceSecret = '7c'.repeat(32); + const rotationSecret = '7d'.repeat(32); + instance.state.creditNote(sourceSecret, 21_000); + // the rotate landed before the crash: the mint credited the staged secret + instance.state.creditNote(rotationSecret, 21_000); + const [source] = await wallet.addBearers( + [liveNote(instance, sourceSecret)], + wallet.captureOwnerFence(), + ); + if (!source) throw new Error('Expected a source bearer.'); + const [staged] = await wallet.addBearers( + [refreshStage(instance, rotationSecret, source.id)], + wallet.captureOwnerFence(), + ); + if (!staged) throw new Error('Expected a staged bearer.'); + const writes = vi.spyOn(storage, 'setItem'); + + await wallet.recoverPendingMints(wallet.captureOwnerFence()); + + // one commit: the rotated note finalizes under the staged id and the + // held source retires atomically with it - the balance never doubles + expect(writes.mock.calls.filter(([key]) => key === 'sattle_funds_v2')).toHaveLength(1); + expect(wallet.bearers.find((bearer) => bearer.id === source.id)?.spent).toBe(true); + expect(wallet.bearers.find((bearer) => bearer.id === staged.id)).toMatchObject({ + amount: 21_000, + callback: `${instance.url}/w/cb`, + verified: true, + pendingMint: undefined, + label: 'kept label', + }); + expect(wallet.balanceMsat).toBe(21_000); + }); +}); diff --git a/src/stores/walletFunds.ts b/src/stores/walletFunds.ts index 5285912..6090c5b 100644 --- a/src/stores/walletFunds.ts +++ b/src/stores/walletFunds.ts @@ -319,7 +319,8 @@ export const createWalletFunds = (options: WalletFundsOptions) => { } throw new Error('The wallet record is not a pending mint output.'); } - const sourceBearerId = current.pendingMint.sourceBearerId; + const sourceBearerId = + current.pendingMint.sourceBearerId ?? current.pendingMint.refreshSourceBearerId; if (sourceBearerId && !bearers.value.some((bearer) => bearer.id === sourceBearerId)) { throw new BearerNotFoundError(); } @@ -350,7 +351,8 @@ export const createWalletFunds = (options: WalletFundsOptions) => { const current = bearers.value.find((bearer) => bearer.id === id); if (!current) throw new BearerNotFoundError(); if (!current.pendingMint) return; - const sourceBearerId = current.pendingMint.sourceBearerId; + const sourceBearerId = + current.pendingMint.sourceBearerId ?? current.pendingMint.refreshSourceBearerId; ownerFence(); const spent: Bearer = { ...current, @@ -399,6 +401,13 @@ export const createWalletFunds = (options: WalletFundsOptions) => { const recovered = await recoverStagedMintOutput(staged, { assertOwner: ownerFence }); switch (recovered.state) { case 'unminted': + if (staged.pendingMint.refreshSourceBearerId) { + // A held-note refresh keeps its source live until the rotate + // lands. No output at the staged secret means the source is + // still the wallet's valid copy and this stage is disposable. + await removeNote(staged.id, ownerFence); + break; + } if ( !staged.pendingMint.sourceBearerId && staged.pendingMint.retireAfter !== undefined && @@ -434,7 +443,7 @@ export const createWalletFunds = (options: WalletFundsOptions) => { await recoverLinkedSource(); break; case 'pending': - await recoverLinkedSource(); + if (!staged.pendingMint.refreshSourceBearerId) await recoverLinkedSource(); break; case 'minted': await finalizeStagedMintOutput(staged.id, recovered.note, ownerFence); @@ -445,7 +454,7 @@ export const createWalletFunds = (options: WalletFundsOptions) => { } } catch (error) { ownerFence(); - if (staged.pendingMint.sourceBearerId) { + if (staged.pendingMint.sourceBearerId && !staged.pendingMint.refreshSourceBearerId) { try { const source = bearers.value.find( (bearer) => bearer.id === staged.pendingMint?.sourceBearerId,