4 Commits
Author SHA1 Message Date
protom 9bea2d3043 Merge pull request #12 from tompro/release-please--branches--main--components--sattle
chore(main): release sattle 0.0.6
2026-09-07 14:45:09 +02:00
github-actions[bot] a057bde3ff chore(main): release sattle 0.0.6 2026-09-07 11:39:06 +00:00
protom 785b56de3b Merge pull request #11 from tompro/feat/notes-and-activity
feat: add note management and activity history
2026-09-07 13:38:49 +02:00
protom 644e64ee10 feat: add note management and activity history 2026-09-07 10:43:30 +02:00
23 changed files with 1327 additions and 19 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "0.0.5"
".": "0.0.6"
}
+8
View File
@@ -1,5 +1,13 @@
# 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)
+135
View File
@@ -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');
});
});
+156
View File
@@ -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();
});
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sattle",
"version": "0.0.5",
"version": "0.0.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sattle",
"version": "0.0.5",
"version": "0.0.6",
"hasInstallScript": true,
"dependencies": {
"@agicash/qr-scanner": "^0.1.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sattle",
"version": "0.0.5",
"version": "0.0.6",
"description": "A Quasar Project",
"productName": "sattle",
"author": "protom <office@protom.eu>",
+14 -4
View File
@@ -1,19 +1,28 @@
<script setup lang="ts">
// Unified activity history: renders the activity store's events newest
// first, one row per wallet action. Self-contained - designed to sit inside
// a q-expansion-item on the home page.
// first, one row per wallet action. The caller can provide a page-sized slice
// or cap the store-backed list for the home-page preview.
import { computed } from 'vue';
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();
// 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> = {
mint: 'arrow_downward',
receive: 'south_west',
refresh: 'refresh',
melt: 'north_east',
split: 'shuffle',
combine: 'shuffle',
@@ -26,6 +35,7 @@ const KIND_ICONS: Record<ActivityKind, string> = {
const KIND_COLORS: Record<ActivityKind, string> = {
mint: 'positive',
receive: 'positive',
refresh: 'info',
melt: 'primary',
split: 'info',
combine: 'info',
+25 -3
View File
@@ -1,6 +1,6 @@
// allow: SIZE_OK — cohesive move-funds UI state machine spanning quote through durable outcome.
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { useQuasar } from 'quasar';
import { describeMintFee, noteK1, serverOf } from 'lnurlcash-kit';
import type { MintFee } from 'lnurlcash-kit';
@@ -28,6 +28,7 @@ type TransferResult = Readonly<{
export const useMoveFundsPage = () => {
const router = useRouter();
const route = useRoute();
const $q = useQuasar();
const wallet = useWalletStore();
const mints = useMintsStore();
@@ -47,9 +48,20 @@ export const useMoveFundsPage = () => {
);
const CUSTOM_TARGET = '__custom__';
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 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))
continue;
const server = serverOf(bearer.url);
@@ -71,6 +83,15 @@ export const useMoveFundsPage = () => {
const inlineError = ref('');
const stage = ref('');
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 options: Option[] = [];
for (const mint of mints.mints) {
@@ -147,7 +168,7 @@ export const useMoveFundsPage = () => {
// never leave burned inputs looking spendable or strand the outputs
const carveState: { committed?: Bearer } = {};
const transfer = await transferBetweenMints(
wallet.bearers.filter((bearer) => serverOf(bearer.url) === sourceServer.value),
sourceBearers.value.filter((bearer) => serverOf(bearer.url) === sourceServer.value),
satsToMsat(sats),
targetInput.value,
{
@@ -270,6 +291,7 @@ export const useMoveFundsPage = () => {
setMax,
sourceOptions,
sourceServer,
selectedNote,
stage,
step,
targetChoice,
+131
View File
@@ -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();
});
});
+312
View File
@@ -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,
};
};
+23
View File
@@ -144,6 +144,29 @@ describe('transferBetweenMints', () => {
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 () => {
const instance = await mint();
const k1 = secret('42');
+49
View File
@@ -104,6 +104,26 @@ describe('encrypted bearer records', () => {
clearAllBearers();
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', () => {
@@ -116,6 +136,35 @@ describe('activity log', () => {
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 () => {
const key = await deriveBearerAesKey(LINKING_KEY);
for (let i = 0; i < MAX_ACTIVITY_ENTRIES + 5; i++) {
+2
View File
@@ -17,6 +17,7 @@ export type ActivityKind =
| 'melt'
| 'transfer'
| 'receive'
| 'refresh'
| 'spent'
| 'deleted'
// 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 'transfer':
case 'receive':
case 'refresh':
case 'spent':
case 'deleted':
case 'nwc':
+2
View File
@@ -107,6 +107,8 @@ const isStoredBearer = (value: unknown): value is Omit<Bearer, 'id'> =>
(isJsonObject(value.pendingMint) &&
(value.pendingMint.sourceBearerId === undefined ||
typeof value.pendingMint.sourceBearerId === 'string') &&
(value.pendingMint.refreshSourceBearerId === undefined ||
typeof value.pendingMint.refreshSourceBearerId === 'string') &&
(value.pendingMint.sourceRecoverySecret === undefined ||
typeof value.pendingMint.sourceRecoverySecret === 'string') &&
(value.pendingMint.mintPubkey === undefined ||
+3
View File
@@ -40,6 +40,7 @@ export type Bearer = {
// source record that must be locked when a late settlement is recovered.
pendingMint?: {
sourceBearerId?: string;
refreshSourceBearerId?: string;
sourceRecoverySecret?: string;
mintPubkey?: string;
retireAfter?: number;
@@ -54,9 +55,11 @@ export type NewBearer = {
amount: number;
verified: boolean;
mintPubkey?: string;
label?: string;
deviceId?: string;
pendingMint?: {
sourceBearerId?: string;
refreshSourceBearerId?: string;
sourceRecoverySecret?: string;
mintPubkey?: string;
retireAfter?: number;
+62
View File
@@ -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
View File
@@ -100,7 +100,16 @@
label="History"
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>
</div>
</div>
+2
View File
@@ -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,
+273
View File
@@ -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>
+6 -3
View File
@@ -39,13 +39,17 @@
</q-item-section>
</q-item>
</q-list>
<div class="text-center text-caption text-grey-7 q-py-sm">sattle v{{ version }}</div>
</q-page>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router';
import packageMetadata from '../../package.json';
const router = useRouter();
const version = packageMetadata.version;
type SettingsItem = { label: string; to?: string };
@@ -78,13 +82,12 @@ const groups: { label: string; items: SettingsItem[] }[] = [
{
label: 'Advanced',
items: [
{ label: 'Notes' },
{ label: 'Notes', to: '/settings/notes' },
{ label: 'Offline mode' },
{ label: 'Activity log' },
{ label: 'Activity log', to: '/settings/activity' },
{ label: 'Export / import' },
{ label: 'Developer' },
],
},
{ label: 'About', items: [{ label: 'Docs' }, { label: 'Protocol' }] },
];
</script>
+8
View File
@@ -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'),
},
],
},
{
+89
View File
@@ -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);
});
});
+13 -4
View File
@@ -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,