mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bea2d3043 | ||
|
|
a057bde3ff | ||
|
|
785b56de3b | ||
|
|
644e64ee10 |
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "0.0.5"
|
".": "0.0.6"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.0.6](https://github.com/tompro/sattle/compare/sattle-v0.0.5...sattle-v0.0.6) (2026-09-07)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add note management and activity history ([785b56d](https://github.com/tompro/sattle/commit/785b56de3b34fb3752abd7fa637aaa4773380924))
|
||||||
|
* add note management and activity history ([644e64e](https://github.com/tompro/sattle/commit/644e64ee10bba66609acf5161e176770b5bf5ee1))
|
||||||
|
|
||||||
## [0.0.5](https://github.com/tompro/sattle/compare/sattle-v0.0.4...sattle-v0.0.5) (2026-09-05)
|
## [0.0.5](https://github.com/tompro/sattle/compare/sattle-v0.0.4...sattle-v0.0.5) (2026-09-05)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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<void> => {
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sattle",
|
"name": "sattle",
|
||||||
"version": "0.0.5",
|
"version": "0.0.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sattle",
|
"name": "sattle",
|
||||||
"version": "0.0.5",
|
"version": "0.0.6",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agicash/qr-scanner": "^0.1.2",
|
"@agicash/qr-scanner": "^0.1.2",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sattle",
|
"name": "sattle",
|
||||||
"version": "0.0.5",
|
"version": "0.0.6",
|
||||||
"description": "A Quasar Project",
|
"description": "A Quasar Project",
|
||||||
"productName": "sattle",
|
"productName": "sattle",
|
||||||
"author": "protom <office@protom.eu>",
|
"author": "protom <office@protom.eu>",
|
||||||
|
|||||||
@@ -1,19 +1,28 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// Unified activity history: renders the activity store's events newest
|
// Unified activity history: renders the activity store's events newest
|
||||||
// first, one row per wallet action. Self-contained - designed to sit inside
|
// first, one row per wallet action. The caller can provide a page-sized slice
|
||||||
// a q-expansion-item on the home page.
|
// or cap the store-backed list for the home-page preview.
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useActivityStore } from '@/stores/activity';
|
import { useActivityStore } from '@/stores/activity';
|
||||||
import type { ActivityKind } from '@/lnurlcash/storage';
|
import type { ActivityEvent, ActivityKind } from '@/lnurlcash/storage';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
events?: ActivityEvent[];
|
||||||
|
limit?: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
const activity = useActivityStore();
|
const activity = useActivityStore();
|
||||||
|
|
||||||
// the store already prepends new events, so the array is newest-first
|
// the store already prepends new events, so the array is newest-first
|
||||||
const events = computed(() => activity.events);
|
const events = computed(() => {
|
||||||
|
const source = props.events ?? activity.events;
|
||||||
|
return props.limit === undefined ? source : source.slice(0, props.limit);
|
||||||
|
});
|
||||||
|
|
||||||
const KIND_ICONS: Record<ActivityKind, string> = {
|
const KIND_ICONS: Record<ActivityKind, string> = {
|
||||||
mint: 'arrow_downward',
|
mint: 'arrow_downward',
|
||||||
receive: 'south_west',
|
receive: 'south_west',
|
||||||
|
refresh: 'refresh',
|
||||||
melt: 'north_east',
|
melt: 'north_east',
|
||||||
split: 'shuffle',
|
split: 'shuffle',
|
||||||
combine: 'shuffle',
|
combine: 'shuffle',
|
||||||
@@ -26,6 +35,7 @@ const KIND_ICONS: Record<ActivityKind, string> = {
|
|||||||
const KIND_COLORS: Record<ActivityKind, string> = {
|
const KIND_COLORS: Record<ActivityKind, string> = {
|
||||||
mint: 'positive',
|
mint: 'positive',
|
||||||
receive: 'positive',
|
receive: 'positive',
|
||||||
|
refresh: 'info',
|
||||||
melt: 'primary',
|
melt: 'primary',
|
||||||
split: 'info',
|
split: 'info',
|
||||||
combine: 'info',
|
combine: 'info',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// allow: SIZE_OK — cohesive move-funds UI state machine spanning quote through durable outcome.
|
// allow: SIZE_OK — cohesive move-funds UI state machine spanning quote through durable outcome.
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { useQuasar } from 'quasar';
|
import { useQuasar } from 'quasar';
|
||||||
import { describeMintFee, noteK1, serverOf } from 'lnurlcash-kit';
|
import { describeMintFee, noteK1, serverOf } from 'lnurlcash-kit';
|
||||||
import type { MintFee } from 'lnurlcash-kit';
|
import type { MintFee } from 'lnurlcash-kit';
|
||||||
@@ -28,6 +28,7 @@ type TransferResult = Readonly<{
|
|||||||
|
|
||||||
export const useMoveFundsPage = () => {
|
export const useMoveFundsPage = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
const $q = useQuasar();
|
const $q = useQuasar();
|
||||||
const wallet = useWalletStore();
|
const wallet = useWalletStore();
|
||||||
const mints = useMintsStore();
|
const mints = useMintsStore();
|
||||||
@@ -47,9 +48,20 @@ export const useMoveFundsPage = () => {
|
|||||||
);
|
);
|
||||||
const CUSTOM_TARGET = '__custom__';
|
const CUSTOM_TARGET = '__custom__';
|
||||||
const displaySats = (msat: number): number => floorMsatToSat(msat) / MSAT_PER_SAT;
|
const displaySats = (msat: number): number => floorMsatToSat(msat) / MSAT_PER_SAT;
|
||||||
|
const requestedNoteId = computed(() =>
|
||||||
|
typeof route.query.noteId === 'string' ? route.query.noteId : '',
|
||||||
|
);
|
||||||
|
const selectedNote = computed(() =>
|
||||||
|
requestedNoteId.value
|
||||||
|
? wallet.bearers.find((bearer) => bearer.id === requestedNoteId.value)
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
const sourceBearers = computed(() =>
|
||||||
|
selectedNote.value ? [selectedNote.value] : wallet.bearers,
|
||||||
|
);
|
||||||
const spendableByServerMsat = computed(() => {
|
const spendableByServerMsat = computed(() => {
|
||||||
const byServer = new Map<string, number>();
|
const byServer = new Map<string, number>();
|
||||||
for (const bearer of wallet.bearers) {
|
for (const bearer of sourceBearers.value) {
|
||||||
if (bearer.spent || bearer.callback === '' || bearer.deviceId || !noteK1(bearer.url))
|
if (bearer.spent || bearer.callback === '' || bearer.deviceId || !noteK1(bearer.url))
|
||||||
continue;
|
continue;
|
||||||
const server = serverOf(bearer.url);
|
const server = serverOf(bearer.url);
|
||||||
@@ -71,6 +83,15 @@ export const useMoveFundsPage = () => {
|
|||||||
const inlineError = ref('');
|
const inlineError = ref('');
|
||||||
const stage = ref('');
|
const stage = ref('');
|
||||||
const result = ref<TransferResult | null>(null);
|
const result = ref<TransferResult | null>(null);
|
||||||
|
watch(
|
||||||
|
selectedNote,
|
||||||
|
(note) => {
|
||||||
|
if (!note) return;
|
||||||
|
sourceServer.value = serverOf(note.url);
|
||||||
|
amountSats.value = displaySats(note.amount);
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
const targetOptions = computed<Option[]>(() => {
|
const targetOptions = computed<Option[]>(() => {
|
||||||
const options: Option[] = [];
|
const options: Option[] = [];
|
||||||
for (const mint of mints.mints) {
|
for (const mint of mints.mints) {
|
||||||
@@ -147,7 +168,7 @@ export const useMoveFundsPage = () => {
|
|||||||
// never leave burned inputs looking spendable or strand the outputs
|
// never leave burned inputs looking spendable or strand the outputs
|
||||||
const carveState: { committed?: Bearer } = {};
|
const carveState: { committed?: Bearer } = {};
|
||||||
const transfer = await transferBetweenMints(
|
const transfer = await transferBetweenMints(
|
||||||
wallet.bearers.filter((bearer) => serverOf(bearer.url) === sourceServer.value),
|
sourceBearers.value.filter((bearer) => serverOf(bearer.url) === sourceServer.value),
|
||||||
satsToMsat(sats),
|
satsToMsat(sats),
|
||||||
targetInput.value,
|
targetInput.value,
|
||||||
{
|
{
|
||||||
@@ -270,6 +291,7 @@ export const useMoveFundsPage = () => {
|
|||||||
setMax,
|
setMax,
|
||||||
sourceOptions,
|
sourceOptions,
|
||||||
sourceServer,
|
sourceServer,
|
||||||
|
selectedNote,
|
||||||
stage,
|
stage,
|
||||||
step,
|
step,
|
||||||
targetChoice,
|
targetChoice,
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { buildNoteUrl, NoteSpentError } from 'lnurlcash-kit';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type * as OpsExports from '@/lnurlcash/ops';
|
||||||
|
import type { NewBearer } from '@/lnurlcash/types';
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
addBearers: vi.fn(),
|
||||||
|
applyChangeset: vi.fn(),
|
||||||
|
notify: vi.fn(),
|
||||||
|
receiveBearer: vi.fn(),
|
||||||
|
removeNote: vi.fn(),
|
||||||
|
routerReplace: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const source = {
|
||||||
|
id: 'source',
|
||||||
|
url: buildNoteUrl('https://mint.test/note', '11'.repeat(32), 21_000),
|
||||||
|
callback: 'https://mint.test/callback',
|
||||||
|
amount: 21_000,
|
||||||
|
verified: true,
|
||||||
|
mintPubkey: `02${'aa'.repeat(32)}`,
|
||||||
|
label: 'Emergency cash',
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const staged = {
|
||||||
|
id: 'staged',
|
||||||
|
url: buildNoteUrl('https://mint.test/note', '22'.repeat(32), 21_000),
|
||||||
|
callback: source.callback,
|
||||||
|
amount: 0,
|
||||||
|
verified: false,
|
||||||
|
label: source.label,
|
||||||
|
pendingMint: {
|
||||||
|
refreshSourceBearerId: source.id,
|
||||||
|
sourceRecoverySecret: '11'.repeat(32),
|
||||||
|
},
|
||||||
|
createdAt: 2,
|
||||||
|
updatedAt: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const wallet = {
|
||||||
|
state: 'unlocked' as const,
|
||||||
|
pubkey: null,
|
||||||
|
bearers: [source],
|
||||||
|
beginFundOperation: () => ({ ownerFence: () => undefined, complete: () => undefined }),
|
||||||
|
allocateOutputSecrets: vi.fn(),
|
||||||
|
addBearers: mocks.addBearers,
|
||||||
|
applyChangeset: mocks.applyChangeset,
|
||||||
|
commitCarve: vi.fn(),
|
||||||
|
finalizeStagedMintOutput: vi.fn(),
|
||||||
|
markSpent: vi.fn(),
|
||||||
|
removeNote: mocks.removeNote,
|
||||||
|
updateBearer: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('vue-router', () => ({
|
||||||
|
useRouter: () => ({ push: vi.fn(), replace: mocks.routerReplace }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('quasar', () => ({
|
||||||
|
useQuasar: () => ({ notify: mocks.notify }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/stores/wallet', () => ({
|
||||||
|
TrustedMintPostCommitError: class TrustedMintPostCommitError extends Error {},
|
||||||
|
useWalletStore: () => wallet,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/stores/activity', () => ({
|
||||||
|
useActivityStore: () => ({ log: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lnurlcash/trustedMints', () => ({
|
||||||
|
getTrustedMintVerificationKeys: () => [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lnurlcash/ops', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof OpsExports>();
|
||||||
|
return { ...actual, receiveBearer: mocks.receiveBearer };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { useNotesPage } from './useNotesPage';
|
||||||
|
|
||||||
|
describe('note refresh persistence', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
wallet.bearers = [source];
|
||||||
|
mocks.addBearers.mockResolvedValue([staged]);
|
||||||
|
mocks.applyChangeset.mockResolvedValue([]);
|
||||||
|
mocks.receiveBearer.mockImplementation(
|
||||||
|
async (
|
||||||
|
_input: string,
|
||||||
|
_existing: unknown[],
|
||||||
|
options: { stageRotation?: (note: NewBearer) => void | Promise<void> },
|
||||||
|
) => {
|
||||||
|
await options.stageRotation?.({
|
||||||
|
url: staged.url,
|
||||||
|
callback: staged.callback,
|
||||||
|
amount: source.amount,
|
||||||
|
verified: false,
|
||||||
|
pendingMint: { sourceRecoverySecret: '11'.repeat(32) },
|
||||||
|
});
|
||||||
|
throw new NoteSpentError('Note is spent');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stages at zero with local metadata and atomically retires a definitively dead source', async () => {
|
||||||
|
const notes = useNotesPage();
|
||||||
|
|
||||||
|
await notes.refreshNote(source);
|
||||||
|
|
||||||
|
expect(mocks.addBearers).toHaveBeenCalledWith(
|
||||||
|
[
|
||||||
|
expect.objectContaining({
|
||||||
|
amount: 0,
|
||||||
|
label: 'Emergency cash',
|
||||||
|
pendingMint: expect.objectContaining({ refreshSourceBearerId: source.id }),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
expect(mocks.applyChangeset).toHaveBeenCalledWith(
|
||||||
|
{ add: [], markSpent: [source.id], remove: [staged.id] },
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
expect(mocks.removeNote).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import {
|
||||||
|
NoteSpentError,
|
||||||
|
NoteUnknownError,
|
||||||
|
PendingNoteError,
|
||||||
|
noteK1,
|
||||||
|
serverOf,
|
||||||
|
} from 'lnurlcash-kit';
|
||||||
|
|
||||||
|
import { compareBearerOrder } from '@/lnurlcash/storage';
|
||||||
|
import { ensureExactAmount, receiveBearer, UncertainOutcomeError } from '@/lnurlcash/ops';
|
||||||
|
import { getTrustedMintVerificationKeys } from '@/lnurlcash/trustedMints';
|
||||||
|
import type { Bearer } from '@/lnurlcash/types';
|
||||||
|
import { msatToSats, satsToMsat } from '@/lnurlcash/units';
|
||||||
|
import { useActivityStore } from '@/stores/activity';
|
||||||
|
import { TrustedMintPostCommitError, useWalletStore } from '@/stores/wallet';
|
||||||
|
import { addCommittedBearers, commitCarve } from './walletCarveCommit';
|
||||||
|
|
||||||
|
export const useNotesPage = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const $q = useQuasar();
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
const activity = useActivityStore();
|
||||||
|
const selectedIds = ref<string[]>([]);
|
||||||
|
const detail = ref<Bearer | null>(null);
|
||||||
|
const splitOpen = ref(false);
|
||||||
|
const splitAmount = ref<number | null>(null);
|
||||||
|
const busy = ref(false);
|
||||||
|
|
||||||
|
const notify = (type: 'positive' | 'negative' | 'warning', message: string): void => {
|
||||||
|
$q.notify({ type, message, position: 'top', timeout: 3500 });
|
||||||
|
};
|
||||||
|
const warnCommitted = (message: string): void => notify('warning', message);
|
||||||
|
const notes = computed(() => [...wallet.bearers].sort(compareBearerOrder));
|
||||||
|
const selectedNotes = computed(() =>
|
||||||
|
selectedIds.value
|
||||||
|
.map((id) => wallet.bearers.find((note) => note.id === id))
|
||||||
|
.filter((note): note is Bearer => note !== undefined),
|
||||||
|
);
|
||||||
|
const canMutate = (note: Bearer): boolean =>
|
||||||
|
note.verified &&
|
||||||
|
!note.spent &&
|
||||||
|
!note.pendingMint &&
|
||||||
|
!note.deviceId &&
|
||||||
|
note.callback !== '' &&
|
||||||
|
noteK1(note.url) !== null;
|
||||||
|
const canMerge = computed(() => {
|
||||||
|
if (selectedNotes.value.length < 2 || selectedNotes.value.some((note) => !canMutate(note))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const first = selectedNotes.value[0];
|
||||||
|
if (!first) return false;
|
||||||
|
const server = serverOf(first.url);
|
||||||
|
return selectedNotes.value.every((note) => serverOf(note.url) === server);
|
||||||
|
});
|
||||||
|
const formatSats = (amountMsat: number): string =>
|
||||||
|
msatToSats(amountMsat).toLocaleString(undefined, { maximumFractionDigits: 3 });
|
||||||
|
const status = (note: Bearer): string => {
|
||||||
|
if (note.spent) return 'Spent';
|
||||||
|
if (note.pendingMint) return 'Pending';
|
||||||
|
if (note.deviceId) return 'Paired device';
|
||||||
|
return note.verified ? 'Valid locally' : 'Unverified';
|
||||||
|
};
|
||||||
|
const statusColor = (note: Bearer): string => {
|
||||||
|
if (note.spent) return 'grey-6';
|
||||||
|
if (note.pendingMint) return 'warning';
|
||||||
|
return note.verified ? 'positive' : 'warning';
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => wallet.state,
|
||||||
|
(state) => {
|
||||||
|
if (state !== 'unlocked') void router.replace('/');
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
watch(
|
||||||
|
() => wallet.bearers,
|
||||||
|
() => {
|
||||||
|
selectedIds.value = selectedIds.value.filter((id) => wallet.bearers.some((n) => n.id === id));
|
||||||
|
if (detail.value)
|
||||||
|
detail.value = wallet.bearers.find((n) => n.id === detail.value?.id) ?? null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const carve = async (inputs: Bearer[], amountMsat: number, kind: 'split' | 'combine') => {
|
||||||
|
busy.value = true;
|
||||||
|
let operation: ReturnType<typeof wallet.beginFundOperation> | undefined;
|
||||||
|
try {
|
||||||
|
operation = wallet.beginFundOperation();
|
||||||
|
const ownerFence = operation.ownerFence;
|
||||||
|
const ownerId = wallet.pubkey ?? undefined;
|
||||||
|
const result = await ensureExactAmount(inputs, amountMsat, {
|
||||||
|
assertOwner: ownerFence,
|
||||||
|
allocateOutputSecrets: (server, count) =>
|
||||||
|
wallet.allocateOutputSecrets(server, count, ownerFence),
|
||||||
|
mintSignatureKeys: (server) => getTrustedMintVerificationKeys(server, ownerId),
|
||||||
|
onCarve: async (checkpoint) => {
|
||||||
|
await commitCarve(wallet, checkpoint, { ownerFence, warn: warnCommitted });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await activity.log(
|
||||||
|
kind,
|
||||||
|
kind === 'combine'
|
||||||
|
? `Combined ${inputs.length} notes into ${formatSats(result.note.amount)} sats.`
|
||||||
|
: `Split a note into ${formatSats(result.note.amount)} and ${formatSats(result.change?.amount ?? 0)} sats.`,
|
||||||
|
(error) => warnCommitted(error.message),
|
||||||
|
);
|
||||||
|
selectedIds.value = [];
|
||||||
|
splitOpen.value = false;
|
||||||
|
detail.value = null;
|
||||||
|
notify('positive', kind === 'combine' ? 'Notes combined.' : 'Note split.');
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof UncertainOutcomeError && operation) {
|
||||||
|
await addCommittedBearers(wallet, error.possibleOutputs, {
|
||||||
|
ownerFence: operation.ownerFence,
|
||||||
|
warn: warnCommitted,
|
||||||
|
});
|
||||||
|
notify('warning', 'The mint result is uncertain. Possible outputs are saved unverified.');
|
||||||
|
} else {
|
||||||
|
notify('negative', error instanceof Error ? error.message : 'The note operation failed.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
operation?.complete();
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeSelected = (): Promise<void> => {
|
||||||
|
if (!canMerge.value) return Promise.resolve();
|
||||||
|
return carve(
|
||||||
|
selectedNotes.value,
|
||||||
|
selectedNotes.value.reduce((total, note) => total + note.amount, 0),
|
||||||
|
'combine',
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const openSplit = (note: Bearer): void => {
|
||||||
|
detail.value = note;
|
||||||
|
splitAmount.value = null;
|
||||||
|
splitOpen.value = true;
|
||||||
|
};
|
||||||
|
const confirmSplit = (): Promise<void> => {
|
||||||
|
const note = detail.value;
|
||||||
|
const sats = splitAmount.value;
|
||||||
|
if (!note || !sats || !Number.isInteger(sats) || satsToMsat(sats) >= note.amount) {
|
||||||
|
notify('negative', 'Enter a whole-sat amount smaller than the note.');
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return carve([note], satsToMsat(sats), 'split');
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshNote = async (note: Bearer): Promise<void> => {
|
||||||
|
if (note.spent || note.pendingMint || note.deviceId || !noteK1(note.url)) return;
|
||||||
|
busy.value = true;
|
||||||
|
let operation: ReturnType<typeof wallet.beginFundOperation> | undefined;
|
||||||
|
let staged: Bearer | undefined;
|
||||||
|
let refreshReturned = false;
|
||||||
|
try {
|
||||||
|
operation = wallet.beginFundOperation();
|
||||||
|
const ownerFence = operation.ownerFence;
|
||||||
|
const refreshed = await receiveBearer(
|
||||||
|
note.url,
|
||||||
|
wallet.bearers.filter((candidate) => candidate.id !== note.id),
|
||||||
|
{
|
||||||
|
assertOwner: ownerFence,
|
||||||
|
allocateOutputSecrets: (server, count) =>
|
||||||
|
wallet.allocateOutputSecrets(server, count, ownerFence),
|
||||||
|
stageRotation: async (output) => {
|
||||||
|
[staged] = await addCommittedBearers(
|
||||||
|
wallet,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
...output,
|
||||||
|
amount: 0,
|
||||||
|
label: note.label,
|
||||||
|
pendingMint: { ...output.pendingMint, refreshSourceBearerId: note.id },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ ownerFence, warn: warnCommitted },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
refreshReturned = true;
|
||||||
|
if (refreshed.stage === 'finalize') {
|
||||||
|
if (!staged) throw new Error('The refreshed note was not staged.');
|
||||||
|
try {
|
||||||
|
await wallet.finalizeStagedMintOutput(staged.id, refreshed.note, ownerFence);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof TrustedMintPostCommitError)) throw error;
|
||||||
|
warnCommitted(error.message);
|
||||||
|
}
|
||||||
|
} else if (refreshed.stage === 'discard') {
|
||||||
|
if (staged) await wallet.removeNote(staged.id, ownerFence);
|
||||||
|
await wallet.updateBearer(
|
||||||
|
note.id,
|
||||||
|
{
|
||||||
|
callback: refreshed.note.callback,
|
||||||
|
amount: refreshed.note.amount,
|
||||||
|
verified: refreshed.note.verified,
|
||||||
|
mintPubkey: refreshed.note.mintPubkey,
|
||||||
|
},
|
||||||
|
ownerFence,
|
||||||
|
);
|
||||||
|
} else if (refreshed.stage === 'keep') {
|
||||||
|
await wallet.updateBearer(note.id, { verified: false }, ownerFence);
|
||||||
|
} else {
|
||||||
|
await wallet.updateBearer(
|
||||||
|
note.id,
|
||||||
|
{
|
||||||
|
callback: refreshed.note.callback,
|
||||||
|
amount: refreshed.note.amount,
|
||||||
|
verified: refreshed.note.verified,
|
||||||
|
},
|
||||||
|
ownerFence,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await activity.log(
|
||||||
|
'refresh',
|
||||||
|
`Checked and refreshed a ${formatSats(refreshed.note.amount)} sat note from ${serverOf(note.url)}.`,
|
||||||
|
(error) => warnCommitted(error.message),
|
||||||
|
);
|
||||||
|
detail.value = wallet.bearers.find((candidate) => candidate.id === note.id) ?? null;
|
||||||
|
notify(
|
||||||
|
refreshed.rotated ? 'positive' : 'warning',
|
||||||
|
refreshed.rotated
|
||||||
|
? 'Note is valid and has a fresh secret.'
|
||||||
|
: 'Note checked, but could not be rotated.',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error instanceof NoteSpentError || error instanceof NoteUnknownError) && operation) {
|
||||||
|
try {
|
||||||
|
await wallet.applyChangeset(
|
||||||
|
{
|
||||||
|
add: [],
|
||||||
|
markSpent: [note.id],
|
||||||
|
...(staged ? { remove: [staged.id] } : {}),
|
||||||
|
},
|
||||||
|
operation.ownerFence,
|
||||||
|
);
|
||||||
|
notify(
|
||||||
|
'negative',
|
||||||
|
error instanceof NoteSpentError
|
||||||
|
? 'The mint reports this note spent.'
|
||||||
|
: 'The mint does not know this note.',
|
||||||
|
);
|
||||||
|
} catch (commitError) {
|
||||||
|
notify(
|
||||||
|
'negative',
|
||||||
|
commitError instanceof Error ? commitError.message : 'Could not save the note status.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (error instanceof PendingNoteError) {
|
||||||
|
if (staged && operation) {
|
||||||
|
await wallet.removeNote(staged.id, operation.ownerFence).catch(() => undefined);
|
||||||
|
}
|
||||||
|
notify('warning', 'The note is temporarily locked by a pending payment.');
|
||||||
|
} else {
|
||||||
|
if (staged && operation && !refreshReturned) {
|
||||||
|
await wallet.removeNote(staged.id, operation.ownerFence).catch(() => undefined);
|
||||||
|
}
|
||||||
|
notify('negative', error instanceof Error ? error.message : 'The validity check failed.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
operation?.complete();
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveLabel = async (note: Bearer, label: string): Promise<void> => {
|
||||||
|
const operation = wallet.beginFundOperation();
|
||||||
|
try {
|
||||||
|
await wallet.updateBearer(
|
||||||
|
note.id,
|
||||||
|
{ label: label.trim() || undefined },
|
||||||
|
operation.ownerFence,
|
||||||
|
);
|
||||||
|
notify('positive', 'Label saved.');
|
||||||
|
} catch (error) {
|
||||||
|
notify('negative', error instanceof Error ? error.message : 'Could not save the label.');
|
||||||
|
} finally {
|
||||||
|
operation.complete();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const moveNote = (note: Bearer): void => {
|
||||||
|
void router.push({ path: '/settings/move', query: { noteId: note.id } });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
busy,
|
||||||
|
canMerge,
|
||||||
|
canMutate,
|
||||||
|
confirmSplit,
|
||||||
|
detail,
|
||||||
|
formatSats,
|
||||||
|
mergeSelected,
|
||||||
|
moveNote,
|
||||||
|
notes,
|
||||||
|
openSplit,
|
||||||
|
refreshNote,
|
||||||
|
router,
|
||||||
|
saveLabel,
|
||||||
|
selectedIds,
|
||||||
|
serverOf,
|
||||||
|
splitAmount,
|
||||||
|
splitOpen,
|
||||||
|
status,
|
||||||
|
statusColor,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -144,6 +144,29 @@ describe('transferBetweenMints', () => {
|
|||||||
expect(source.state.noteState(k1)).toBe('outstanding');
|
expect(source.state.noteState(k1)).toBe('outstanding');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('refuses an invoice the single selected note cannot cover, even when its mint holds more', async () => {
|
||||||
|
const source = await mint();
|
||||||
|
const target = await mint();
|
||||||
|
const selectedK1 = secret('55');
|
||||||
|
const untappedK1 = secret('56');
|
||||||
|
// the move page's selected-note constraint: the engine's source list is
|
||||||
|
// ONLY the picked note, so the sibling note at the same mint must not
|
||||||
|
// top the amount up
|
||||||
|
const selected = await makeBearer(source, selectedK1, 5_000);
|
||||||
|
await makeBearer(source, untappedK1, 50_000);
|
||||||
|
let persisted = false;
|
||||||
|
await expect(
|
||||||
|
transferBetweenMintsEngine([selected], 21_000, `mint@127.0.0.1:${target.port}`, {
|
||||||
|
persistOutput: () => {
|
||||||
|
persisted = true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/enough/);
|
||||||
|
expect(persisted).toBe(false);
|
||||||
|
expect(source.state.noteState(selectedK1)).toBe('outstanding');
|
||||||
|
expect(source.state.noteState(untappedK1)).toBe('outstanding');
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects a transfer onto the mint the notes are already on', async () => {
|
it('rejects a transfer onto the mint the notes are already on', async () => {
|
||||||
const instance = await mint();
|
const instance = await mint();
|
||||||
const k1 = secret('42');
|
const k1 = secret('42');
|
||||||
|
|||||||
@@ -104,6 +104,26 @@ describe('encrypted bearer records', () => {
|
|||||||
clearAllBearers();
|
clearAllBearers();
|
||||||
expect(readEncryptedBearers()).toEqual([]);
|
expect(readEncryptedBearers()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('round-trips a staged note-refresh output linked to its held source', async () => {
|
||||||
|
const key = await deriveBearerAesKey(LINKING_KEY);
|
||||||
|
// the refresh stage: a future rotated note whose pendingMint points at
|
||||||
|
// the still-live source this wallet holds until the rotate lands
|
||||||
|
const bearer = bearerFixture({
|
||||||
|
callback: '',
|
||||||
|
amount: 0,
|
||||||
|
verified: false,
|
||||||
|
pendingMint: { refreshSourceBearerId: 'held-source', sourceRecoverySecret: K1_B },
|
||||||
|
});
|
||||||
|
await persistBearer(key, bearer);
|
||||||
|
|
||||||
|
// the link to the held source is ciphertext-only at rest
|
||||||
|
const raw = requiredValue(localStorage.getItem(FUNDS_STORAGE_KEY));
|
||||||
|
expect(raw).not.toContain('held-source');
|
||||||
|
|
||||||
|
const loaded = await loadBearers(key);
|
||||||
|
expect(loaded).toEqual([bearer]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('activity log', () => {
|
describe('activity log', () => {
|
||||||
@@ -116,6 +136,35 @@ describe('activity log', () => {
|
|||||||
expect(loaded.map((e) => e.id)).toEqual(['2', '1']);
|
expect(loaded.map((e) => e.id)).toEqual(['2', '1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('round-trips every activity kind, refresh included', async () => {
|
||||||
|
const key = await deriveBearerAesKey(LINKING_KEY);
|
||||||
|
const kinds = [
|
||||||
|
'mint',
|
||||||
|
'split',
|
||||||
|
'combine',
|
||||||
|
'melt',
|
||||||
|
'transfer',
|
||||||
|
'receive',
|
||||||
|
'refresh',
|
||||||
|
'spent',
|
||||||
|
'deleted',
|
||||||
|
'nwc',
|
||||||
|
] as const;
|
||||||
|
const events = kinds.map((kind, index) => ({
|
||||||
|
id: `ev-${kind}`,
|
||||||
|
kind,
|
||||||
|
message: `did ${kind}`,
|
||||||
|
createdAt: 1000 + index,
|
||||||
|
}));
|
||||||
|
for (const event of events) {
|
||||||
|
await persistActivityEvent(key, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loaded = await loadActivity(key);
|
||||||
|
// a kind the reader rejects would silently vanish from the log
|
||||||
|
expect(loaded).toEqual([...events].reverse());
|
||||||
|
});
|
||||||
|
|
||||||
it('caps the log, rolling the oldest entries off', async () => {
|
it('caps the log, rolling the oldest entries off', async () => {
|
||||||
const key = await deriveBearerAesKey(LINKING_KEY);
|
const key = await deriveBearerAesKey(LINKING_KEY);
|
||||||
for (let i = 0; i < MAX_ACTIVITY_ENTRIES + 5; i++) {
|
for (let i = 0; i < MAX_ACTIVITY_ENTRIES + 5; i++) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export type ActivityKind =
|
|||||||
| 'melt'
|
| 'melt'
|
||||||
| 'transfer'
|
| 'transfer'
|
||||||
| 'receive'
|
| 'receive'
|
||||||
|
| 'refresh'
|
||||||
| 'spent'
|
| 'spent'
|
||||||
| 'deleted'
|
| 'deleted'
|
||||||
// a payment or mint initiated by a Nostr Wallet Connect client (M5)
|
// a payment or mint initiated by a Nostr Wallet Connect client (M5)
|
||||||
@@ -39,6 +40,7 @@ const isActivityKind = (value: unknown): value is ActivityKind => {
|
|||||||
case 'melt':
|
case 'melt':
|
||||||
case 'transfer':
|
case 'transfer':
|
||||||
case 'receive':
|
case 'receive':
|
||||||
|
case 'refresh':
|
||||||
case 'spent':
|
case 'spent':
|
||||||
case 'deleted':
|
case 'deleted':
|
||||||
case 'nwc':
|
case 'nwc':
|
||||||
|
|||||||
@@ -107,6 +107,8 @@ const isStoredBearer = (value: unknown): value is Omit<Bearer, 'id'> =>
|
|||||||
(isJsonObject(value.pendingMint) &&
|
(isJsonObject(value.pendingMint) &&
|
||||||
(value.pendingMint.sourceBearerId === undefined ||
|
(value.pendingMint.sourceBearerId === undefined ||
|
||||||
typeof value.pendingMint.sourceBearerId === 'string') &&
|
typeof value.pendingMint.sourceBearerId === 'string') &&
|
||||||
|
(value.pendingMint.refreshSourceBearerId === undefined ||
|
||||||
|
typeof value.pendingMint.refreshSourceBearerId === 'string') &&
|
||||||
(value.pendingMint.sourceRecoverySecret === undefined ||
|
(value.pendingMint.sourceRecoverySecret === undefined ||
|
||||||
typeof value.pendingMint.sourceRecoverySecret === 'string') &&
|
typeof value.pendingMint.sourceRecoverySecret === 'string') &&
|
||||||
(value.pendingMint.mintPubkey === undefined ||
|
(value.pendingMint.mintPubkey === undefined ||
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export type Bearer = {
|
|||||||
// source record that must be locked when a late settlement is recovered.
|
// source record that must be locked when a late settlement is recovered.
|
||||||
pendingMint?: {
|
pendingMint?: {
|
||||||
sourceBearerId?: string;
|
sourceBearerId?: string;
|
||||||
|
refreshSourceBearerId?: string;
|
||||||
sourceRecoverySecret?: string;
|
sourceRecoverySecret?: string;
|
||||||
mintPubkey?: string;
|
mintPubkey?: string;
|
||||||
retireAfter?: number;
|
retireAfter?: number;
|
||||||
@@ -54,9 +55,11 @@ export type NewBearer = {
|
|||||||
amount: number;
|
amount: number;
|
||||||
verified: boolean;
|
verified: boolean;
|
||||||
mintPubkey?: string;
|
mintPubkey?: string;
|
||||||
|
label?: string;
|
||||||
deviceId?: string;
|
deviceId?: string;
|
||||||
pendingMint?: {
|
pendingMint?: {
|
||||||
sourceBearerId?: string;
|
sourceBearerId?: string;
|
||||||
|
refreshSourceBearerId?: string;
|
||||||
sourceRecoverySecret?: string;
|
sourceRecoverySecret?: string;
|
||||||
mintPubkey?: string;
|
mintPubkey?: string;
|
||||||
retireAfter?: number;
|
retireAfter?: number;
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<template>
|
||||||
|
<q-page class="q-pa-md">
|
||||||
|
<div class="row items-center q-mb-md">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
round
|
||||||
|
color="primary"
|
||||||
|
icon="arrow_back"
|
||||||
|
aria-label="Back"
|
||||||
|
@click="router.push('/settings')"
|
||||||
|
/>
|
||||||
|
<div class="text-h5 text-weight-bold text-primary q-ml-sm">Activity log</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-card class="sattle-card">
|
||||||
|
<HistoryList :events="pageEvents" />
|
||||||
|
<template v-if="pageCount > 1">
|
||||||
|
<q-separator dark />
|
||||||
|
<div class="row justify-center q-pa-md">
|
||||||
|
<q-pagination
|
||||||
|
v-model="page"
|
||||||
|
:max="pageCount"
|
||||||
|
:max-pages="7"
|
||||||
|
direction-links
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</q-card>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import HistoryList from '@/components/HistoryList.vue';
|
||||||
|
import { useActivityStore } from '@/stores/activity';
|
||||||
|
import { useWalletStore } from '@/stores/wallet';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
const router = useRouter();
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
const activity = useActivityStore();
|
||||||
|
const page = ref(1);
|
||||||
|
const pageCount = computed(() => Math.max(1, Math.ceil(activity.events.length / PAGE_SIZE)));
|
||||||
|
const pageEvents = computed(() => {
|
||||||
|
const start = (page.value - 1) * PAGE_SIZE;
|
||||||
|
return activity.events.slice(start, start + PAGE_SIZE);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => wallet.state,
|
||||||
|
(state) => {
|
||||||
|
if (state !== 'unlocked') void router.replace('/');
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
watch(pageCount, (count) => {
|
||||||
|
if (page.value > count) page.value = count;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
+10
-1
@@ -100,7 +100,16 @@
|
|||||||
label="History"
|
label="History"
|
||||||
header-class="text-primary text-body2"
|
header-class="text-primary text-body2"
|
||||||
>
|
>
|
||||||
<HistoryList />
|
<HistoryList :limit="5" />
|
||||||
|
<q-separator dark />
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
no-caps
|
||||||
|
color="primary"
|
||||||
|
label="View all activity"
|
||||||
|
class="full-width"
|
||||||
|
@click="router.push('/settings/activity')"
|
||||||
|
/>
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
outlined
|
outlined
|
||||||
color="primary"
|
color="primary"
|
||||||
label="From mint"
|
label="From mint"
|
||||||
|
:disable="!!selectedNote"
|
||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -250,6 +251,7 @@ const {
|
|||||||
setMax,
|
setMax,
|
||||||
sourceOptions,
|
sourceOptions,
|
||||||
sourceServer,
|
sourceServer,
|
||||||
|
selectedNote,
|
||||||
stage,
|
stage,
|
||||||
step,
|
step,
|
||||||
targetChoice,
|
targetChoice,
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
<template>
|
||||||
|
<q-page class="q-pa-md notes-page">
|
||||||
|
<div class="row items-center q-mb-md">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
round
|
||||||
|
color="primary"
|
||||||
|
icon="arrow_back"
|
||||||
|
aria-label="Back"
|
||||||
|
@click="router.push('/settings')"
|
||||||
|
/>
|
||||||
|
<div class="text-h5 text-weight-bold text-primary q-ml-sm">Notes</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-banner dense class="sattle-card text-grey-4 q-mb-md">
|
||||||
|
Bearer notes are the individual pieces that make up your balance. Select notes from one mint
|
||||||
|
to combine them, or open a note for more actions.
|
||||||
|
</q-banner>
|
||||||
|
|
||||||
|
<q-card v-if="notes.length" class="sattle-card">
|
||||||
|
<q-list separator>
|
||||||
|
<q-item
|
||||||
|
v-for="note in notes"
|
||||||
|
:key="note.id"
|
||||||
|
clickable
|
||||||
|
class="q-py-md"
|
||||||
|
@click="detail = note"
|
||||||
|
>
|
||||||
|
<q-item-section side @click.stop>
|
||||||
|
<q-checkbox
|
||||||
|
v-model="selectedIds"
|
||||||
|
:val="note.id"
|
||||||
|
color="primary"
|
||||||
|
:disable="!canMutate(note) || busy"
|
||||||
|
:aria-label="`Select ${formatSats(note.amount)} sat note`"
|
||||||
|
/>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label class="text-primary text-weight-medium">
|
||||||
|
{{ formatSats(note.amount) }} sats
|
||||||
|
</q-item-label>
|
||||||
|
<q-item-label caption class="text-grey-5 ellipsis">
|
||||||
|
{{ note.label || serverOf(note.url) }}
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-badge outline :color="statusColor(note)" :label="status(note)" />
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<q-card v-else class="sattle-card q-pa-xl text-center text-grey-5">
|
||||||
|
<q-icon name="generating_tokens" size="42px" class="q-mb-sm" />
|
||||||
|
<div>No notes in this wallet yet.</div>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<div v-if="selectedIds.length" class="merge-bar sattle-card q-pa-sm row items-center">
|
||||||
|
<div class="col text-caption text-grey-4 q-pl-sm">{{ selectedIds.length }} selected</div>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
no-caps
|
||||||
|
color="primary"
|
||||||
|
text-color="dark"
|
||||||
|
icon="join_full"
|
||||||
|
label="Combine"
|
||||||
|
:disable="!canMerge || busy"
|
||||||
|
:loading="busy"
|
||||||
|
@click="mergeSelected"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-dialog :model-value="!!detail" @update:model-value="(open) => !open && (detail = null)">
|
||||||
|
<q-card v-if="detail" class="sattle-card note-detail">
|
||||||
|
<q-card-section class="row items-start">
|
||||||
|
<div class="col">
|
||||||
|
<div class="text-h6 text-primary">{{ formatSats(detail.amount) }} sats</div>
|
||||||
|
<div class="text-caption text-grey-5">{{ serverOf(detail.url) }}</div>
|
||||||
|
</div>
|
||||||
|
<q-badge outline :color="statusColor(detail)" :label="status(detail)" />
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-separator dark />
|
||||||
|
<q-list dense class="q-py-sm">
|
||||||
|
<q-item>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label caption class="text-grey-6">Note ID</q-item-label>
|
||||||
|
<q-item-label class="text-grey-3 id-value">{{ detail.id }}</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label caption class="text-grey-6">Created</q-item-label>
|
||||||
|
<q-item-label class="text-grey-3">{{ formatDate(detail.createdAt) }}</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label caption class="text-grey-6">Last updated</q-item-label>
|
||||||
|
<q-item-label class="text-grey-3">{{ formatDate(detail.updatedAt) }}</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item v-if="detail.mintPubkey">
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label caption class="text-grey-6">Mint signing key</q-item-label>
|
||||||
|
<q-item-label class="text-grey-3 id-value">{{ detail.mintPubkey }}</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
|
||||||
|
<q-card-section class="q-pt-none">
|
||||||
|
<q-input
|
||||||
|
v-model="labelDraft"
|
||||||
|
dark
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
color="primary"
|
||||||
|
label="Private label"
|
||||||
|
maxlength="120"
|
||||||
|
>
|
||||||
|
<template #append>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
round
|
||||||
|
color="primary"
|
||||||
|
icon="save"
|
||||||
|
aria-label="Save label"
|
||||||
|
:disable="busy"
|
||||||
|
@click="saveLabel(detail, labelDraft)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-actions class="q-px-md q-pb-md q-gutter-sm">
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
no-caps
|
||||||
|
color="primary"
|
||||||
|
icon="call_split"
|
||||||
|
label="Split"
|
||||||
|
:disable="!canMutate(detail) || detail.amount <= 1000 || busy"
|
||||||
|
@click="openSplit(detail)"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
no-caps
|
||||||
|
color="primary"
|
||||||
|
icon="swap_horiz"
|
||||||
|
label="Move"
|
||||||
|
:disable="!canMutate(detail) || busy"
|
||||||
|
@click="moveNote(detail)"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
no-caps
|
||||||
|
color="primary"
|
||||||
|
text-color="dark"
|
||||||
|
icon="refresh"
|
||||||
|
label="Check & refresh"
|
||||||
|
:disable="detail.spent || !!detail.pendingMint || !!detail.deviceId || busy"
|
||||||
|
:loading="busy"
|
||||||
|
@click="refreshNote(detail)"
|
||||||
|
/>
|
||||||
|
</q-card-actions>
|
||||||
|
<q-card-section class="text-caption text-grey-6 q-pt-none">
|
||||||
|
Checking rotates a valid note to a fresh secret, so the check itself cannot expose a
|
||||||
|
reusable copy.
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
|
<q-dialog v-model="splitOpen" persistent>
|
||||||
|
<q-card class="sattle-card q-pa-md split-dialog">
|
||||||
|
<div class="text-h6 text-primary q-mb-xs">Split note</div>
|
||||||
|
<div class="text-caption text-grey-5 q-mb-md">
|
||||||
|
Enter the value of one new note. The remainder stays as another note.
|
||||||
|
</div>
|
||||||
|
<q-input
|
||||||
|
v-model.number="splitAmount"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
:max="detail ? Math.ceil(detail.amount / 1000) - 1 : undefined"
|
||||||
|
step="1"
|
||||||
|
suffix="sats"
|
||||||
|
label="New note amount"
|
||||||
|
dark
|
||||||
|
outlined
|
||||||
|
color="primary"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<q-card-actions align="right" class="q-mt-md">
|
||||||
|
<q-btn flat no-caps color="grey-5" label="Cancel" :disable="busy" v-close-popup />
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
no-caps
|
||||||
|
color="primary"
|
||||||
|
text-color="dark"
|
||||||
|
label="Split"
|
||||||
|
:loading="busy"
|
||||||
|
@click="confirmSplit"
|
||||||
|
/>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import { useNotesPage } from '@/composables/useNotesPage';
|
||||||
|
|
||||||
|
const {
|
||||||
|
busy,
|
||||||
|
canMerge,
|
||||||
|
canMutate,
|
||||||
|
confirmSplit,
|
||||||
|
detail,
|
||||||
|
formatSats,
|
||||||
|
mergeSelected,
|
||||||
|
moveNote,
|
||||||
|
notes,
|
||||||
|
openSplit,
|
||||||
|
refreshNote,
|
||||||
|
router,
|
||||||
|
saveLabel,
|
||||||
|
selectedIds,
|
||||||
|
serverOf,
|
||||||
|
splitAmount,
|
||||||
|
splitOpen,
|
||||||
|
status,
|
||||||
|
statusColor,
|
||||||
|
} = useNotesPage();
|
||||||
|
|
||||||
|
const labelDraft = ref('');
|
||||||
|
watch(detail, (note) => {
|
||||||
|
labelDraft.value = note?.label ?? '';
|
||||||
|
});
|
||||||
|
const formatDate = (timestamp: number): string => new Date(timestamp).toLocaleString();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.notes-page {
|
||||||
|
padding-bottom: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-bar {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 10;
|
||||||
|
right: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
left: 16px;
|
||||||
|
max-width: 620px;
|
||||||
|
margin: auto;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-detail {
|
||||||
|
width: min(520px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-dialog {
|
||||||
|
width: min(420px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.id-value {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -39,13 +39,17 @@
|
|||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
</q-list>
|
</q-list>
|
||||||
|
|
||||||
|
<div class="text-center text-caption text-grey-7 q-py-sm">sattle v{{ version }}</div>
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
import packageMetadata from '../../package.json';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const version = packageMetadata.version;
|
||||||
|
|
||||||
type SettingsItem = { label: string; to?: string };
|
type SettingsItem = { label: string; to?: string };
|
||||||
|
|
||||||
@@ -78,13 +82,12 @@ const groups: { label: string; items: SettingsItem[] }[] = [
|
|||||||
{
|
{
|
||||||
label: 'Advanced',
|
label: 'Advanced',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Notes' },
|
{ label: 'Notes', to: '/settings/notes' },
|
||||||
{ label: 'Offline mode' },
|
{ label: 'Offline mode' },
|
||||||
{ label: 'Activity log' },
|
{ label: 'Activity log', to: '/settings/activity' },
|
||||||
{ label: 'Export / import' },
|
{ label: 'Export / import' },
|
||||||
{ label: 'Developer' },
|
{ label: 'Developer' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ label: 'About', items: [{ label: 'Docs' }, { label: 'Protocol' }] },
|
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ const routes: RouteRecordRaw[] = [
|
|||||||
path: 'settings/nwc',
|
path: 'settings/nwc',
|
||||||
component: () => import('@/pages/NwcPage.vue'),
|
component: () => import('@/pages/NwcPage.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'settings/notes',
|
||||||
|
component: () => import('@/pages/NotesPage.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings/activity',
|
||||||
|
component: () => import('@/pages/ActivityLogPage.vue'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -427,3 +427,92 @@ describe('pending mint recovery', () => {
|
|||||||
expect(readFundsDocument().nextByHost).toEqual({ 'mint.example': 2 });
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -319,7 +319,8 @@ export const createWalletFunds = (options: WalletFundsOptions) => {
|
|||||||
}
|
}
|
||||||
throw new Error('The wallet record is not a pending mint output.');
|
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)) {
|
if (sourceBearerId && !bearers.value.some((bearer) => bearer.id === sourceBearerId)) {
|
||||||
throw new BearerNotFoundError();
|
throw new BearerNotFoundError();
|
||||||
}
|
}
|
||||||
@@ -350,7 +351,8 @@ export const createWalletFunds = (options: WalletFundsOptions) => {
|
|||||||
const current = bearers.value.find((bearer) => bearer.id === id);
|
const current = bearers.value.find((bearer) => bearer.id === id);
|
||||||
if (!current) throw new BearerNotFoundError();
|
if (!current) throw new BearerNotFoundError();
|
||||||
if (!current.pendingMint) return;
|
if (!current.pendingMint) return;
|
||||||
const sourceBearerId = current.pendingMint.sourceBearerId;
|
const sourceBearerId =
|
||||||
|
current.pendingMint.sourceBearerId ?? current.pendingMint.refreshSourceBearerId;
|
||||||
ownerFence();
|
ownerFence();
|
||||||
const spent: Bearer = {
|
const spent: Bearer = {
|
||||||
...current,
|
...current,
|
||||||
@@ -399,6 +401,13 @@ export const createWalletFunds = (options: WalletFundsOptions) => {
|
|||||||
const recovered = await recoverStagedMintOutput(staged, { assertOwner: ownerFence });
|
const recovered = await recoverStagedMintOutput(staged, { assertOwner: ownerFence });
|
||||||
switch (recovered.state) {
|
switch (recovered.state) {
|
||||||
case 'unminted':
|
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 (
|
if (
|
||||||
!staged.pendingMint.sourceBearerId &&
|
!staged.pendingMint.sourceBearerId &&
|
||||||
staged.pendingMint.retireAfter !== undefined &&
|
staged.pendingMint.retireAfter !== undefined &&
|
||||||
@@ -434,7 +443,7 @@ export const createWalletFunds = (options: WalletFundsOptions) => {
|
|||||||
await recoverLinkedSource();
|
await recoverLinkedSource();
|
||||||
break;
|
break;
|
||||||
case 'pending':
|
case 'pending':
|
||||||
await recoverLinkedSource();
|
if (!staged.pendingMint.refreshSourceBearerId) await recoverLinkedSource();
|
||||||
break;
|
break;
|
||||||
case 'minted':
|
case 'minted':
|
||||||
await finalizeStagedMintOutput(staged.id, recovered.note, ownerFence);
|
await finalizeStagedMintOutput(staged.id, recovered.note, ownerFence);
|
||||||
@@ -445,7 +454,7 @@ export const createWalletFunds = (options: WalletFundsOptions) => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ownerFence();
|
ownerFence();
|
||||||
if (staged.pendingMint.sourceBearerId) {
|
if (staged.pendingMint.sourceBearerId && !staged.pendingMint.refreshSourceBearerId) {
|
||||||
try {
|
try {
|
||||||
const source = bearers.value.find(
|
const source = bearers.value.find(
|
||||||
(bearer) => bearer.id === staged.pendingMint?.sourceBearerId,
|
(bearer) => bearer.id === staged.pendingMint?.sourceBearerId,
|
||||||
|
|||||||
Reference in New Issue
Block a user