fix: await wallet commits in the NWC store

This commit is contained in:
2026-08-22 16:56:37 +02:00
parent 81c28ec16d
commit 99e6ae1b12
2 changed files with 232 additions and 51 deletions
+115
View File
@@ -0,0 +1,115 @@
import { createPinia, setActivePinia } from 'pinia';
import { buildNoteUrl } from 'lnurlcash-kit';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { NwcService, NwcServiceDeps } from '@/lnurlcash/nwc';
import type * as NwcExports from '@/lnurlcash/nwc';
import { stubLocalStorage } from '@/lnurlcash/test-utils';
import type { NewBearer } from '@/lnurlcash/types';
import { useActivityStore } from './activity';
type StartService = (linkingKey: Uint8Array, deps: NwcServiceDeps) => Promise<NwcService>;
const mocks = vi.hoisted(() => ({ startService: vi.fn<StartService>() }));
vi.mock('@/lnurlcash/nwc', async (importOriginal) => ({
...(await importOriginal<typeof NwcExports>()),
startService: mocks.startService,
}));
import { useNwcStore } from './nwc';
import { useWalletStore } from './wallet';
const note = (secret: string): NewBearer => ({
url: buildNoteUrl('https://mint.example/w', secret.repeat(32), 21_000),
callback: 'https://mint.example/w/cb',
amount: 21_000,
verified: true,
});
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
vi.stubGlobal('navigator', {});
stubLocalStorage();
setActivePinia(createPinia());
mocks.startService.mockResolvedValue({
connections: [],
stop: vi.fn().mockResolvedValue(undefined),
});
});
describe('NWC store changeset adapter', () => {
it('commits the complete engine changeset in one bearer write', async () => {
const storage = stubLocalStorage();
const wallet = useWalletStore();
await wallet.create();
const [existing] = await wallet.addBearers([note('a')], wallet.captureOwnerFence());
if (!existing) throw new Error('Expected the initial bearer.');
const nwc = useNwcStore();
await nwc.setEnabled(true);
const start = mocks.startService.mock.calls.at(-1);
if (!start) throw new Error('Expected the NWC service to start.');
const writes = vi.spyOn(storage, 'setItem');
await start[1].applyChangeset(
{ add: [note('b')], markSpent: [existing.id] },
{
record: {
version: 1,
ownerId: wallet.pubkey ?? '',
clientPubkey: '11'.repeat(32),
relays: ['wss://relay.example'],
budget: { maxMsat: 100_000, periodMs: 60_000 },
spent: { periodStart: 0, msat: 0 },
createdAt: 1,
},
walletServicePubkey: '22'.repeat(32),
},
'pay_invoice',
start[1].assertCurrentOwner,
);
expect(writes.mock.calls.filter(([key]) => key === 'sattle_bearers')).toHaveLength(1);
expect(wallet.bearers).toHaveLength(2);
expect(wallet.bearers.find(({ id }) => id === existing.id)?.spent).toBe(true);
});
it('surfaces activity durability failure without rolling back committed funds', async () => {
const storage = stubLocalStorage();
const wallet = useWalletStore();
await wallet.create();
const [existing] = await wallet.addBearers([note('a')], wallet.captureOwnerFence());
if (!existing) throw new Error('Expected the initial bearer.');
const nwc = useNwcStore();
await nwc.setEnabled(true);
const start = mocks.startService.mock.calls.at(-1);
if (!start) throw new Error('Expected the NWC service to start.');
const originalSetItem = storage.setItem;
storage.setItem = (key, value): void => {
if (key === 'sattle_activity') throw new Error('activity storage unavailable');
originalSetItem(key, value);
};
await start[1].applyChangeset(
{ add: [], markSpent: [existing.id] },
{
record: {
version: 1,
ownerId: wallet.pubkey ?? '',
clientPubkey: '11'.repeat(32),
relays: ['wss://relay.example'],
budget: { maxMsat: 100_000, periodMs: 60_000 },
spent: { periodStart: 0, msat: 0 },
createdAt: 1,
},
walletServicePubkey: '22'.repeat(32),
},
'pay_invoice',
start[1].assertCurrentOwner,
);
expect(wallet.bearers.find(({ id }) => id === existing.id)?.spent).toBe(true);
expect(useActivityStore().events).toEqual([]);
expect(nwc.lastError).toMatch(/activity history.*do not retry/i);
});
});
+117 -51
View File
@@ -14,12 +14,15 @@ import type {
import { import {
createConnection, createConnection,
persistNwcConnection, persistNwcConnection,
readNwcEnabled,
readNwcConnections, readNwcConnections,
removeNwcConnection, removeNwcConnection,
startService, startService,
writeNwcEnabled,
} from '@/lnurlcash/nwc'; } from '@/lnurlcash/nwc';
import { linkingPubKeyHex } from '@/lnurlcash/keys';
import { msatToSats } from '@/lnurlcash/units'; import { msatToSats } from '@/lnurlcash/units';
import { useWalletStore } from './wallet'; import { TrustedMintPostCommitError, useWalletStore } from './wallet';
import { useMintsStore } from './mints'; import { useMintsStore } from './mints';
import { useActivityStore } from './activity'; import { useActivityStore } from './activity';
@@ -34,12 +37,6 @@ export const NWC_DEFAULT_BUDGET: NwcBudget = {
periodMs: NWC_PERIOD_DAY_MS, periodMs: NWC_PERIOD_DAY_MS,
}; };
// the enabled flag lives outside wallet settings on purpose: settings are
// part of the nostr-backup payload, and a restored device must not start
// answering payment requests before its holder opted in there
const NWC_ENABLED_KEY = 'sattle_nwc_enabled';
const readNwcEnabled = (): boolean => localStorage.getItem(NWC_ENABLED_KEY) === 'true';
// e2e test hook: a fake transport so the suite never touches a real relay. // e2e test hook: a fake transport so the suite never touches a real relay.
// Set before enabling; production never calls this (exposed on window only // Set before enabling; production never calls this (exposed on window only
// in dev builds, at the bottom of this file). // in dev builds, at the bottom of this file).
@@ -48,6 +45,14 @@ export const setNwcTransportForTests = (transport: NwcTransport | null): void =>
transportOverride = transport; transportOverride = transport;
}; };
declare global {
interface Window {
__sattleNwcTest?: {
readonly setTransport: typeof setNwcTransportForTests;
};
}
}
const fingerprint = (pubkey: string): string => const fingerprint = (pubkey: string): string =>
pubkey.length > 18 ? `${pubkey.slice(0, 10)}${pubkey.slice(-8)}` : pubkey; pubkey.length > 18 ? `${pubkey.slice(0, 10)}${pubkey.slice(-8)}` : pubkey;
@@ -64,48 +69,60 @@ export const useNwcStore = defineStore('nwc', () => {
const mints = useMintsStore(); const mints = useMintsStore();
const activity = useActivityStore(); const activity = useActivityStore();
const enabled = ref(readNwcEnabled()); const enabled = ref(false);
const connections = ref<NwcConnectionRecord[]>(readNwcConnections()); const connections = ref<NwcConnectionRecord[]>([]);
const running = ref(false); const running = ref(false);
// background failures (a rejected publish, a lost claim) have no caller // background failures (a rejected publish, a lost claim) have no caller
// to throw to - the page surfaces them here // to throw to - the page surfaces them here
const lastError = ref(''); const lastError = ref('');
const refresh = (): void => { const ownerFromWallet = (): string => linkingPubKeyHex(wallet.requireLinkingKey());
connections.value = readNwcConnections();
const refresh = (ownerId: string = ownerFromWallet()): void => {
connections.value = readNwcConnections(ownerId);
}; };
// ---- changeset application ---- // ---- changeset application ----
// the engine hands money-moving deltas here after an op ran: new notes to // the engine hands money-moving deltas here after an op ran: new notes to
// persist, bearer ids to lock spent. Both go through the wallet store's // persist, bearer ids to lock spent. Both go through the wallet store's
// one entry points (persist-then-state); failures surface as lastError // one entry points (persist-then-state) and are awaited: the engine holds
// rather than vanishing, since the engine already committed its side. // its success answer until this resolves, so a failure rejects back into
const applyChangeset = ( // the engine's onError (surfaced as lastError) instead of a false success.
const applyChangeset = async (
changeset: NwcChangeset, changeset: NwcChangeset,
connection: NwcConnectionInfo, connection: NwcConnectionInfo,
method: NwcMethod, method: NwcMethod,
): void => { ownerFence: () => void,
): Promise<void> => {
const client = fingerprint(connection.record.clientPubkey); const client = fingerprint(connection.record.clientPubkey);
try {
await wallet.applyChangeset(changeset, ownerFence);
} catch (error) {
if (!(error instanceof TrustedMintPostCommitError)) throw error;
lastError.value = error.message;
}
if (method === 'pay_invoice') { if (method === 'pay_invoice') {
// the melt's amount, from the bearers about to be locked spent
const spentMsat = changeset.markSpent.reduce( const spentMsat = changeset.markSpent.reduce(
(sum, id) => sum + (wallet.bearers.find((b) => b.id === id)?.amount ?? 0), (sum, id) => sum + (wallet.bearers.find((b) => b.id === id)?.amount ?? 0),
0, 0,
); );
activity.log('nwc', `NWC client ${client} paid ${formatSats(spentMsat)} sats.`); await activity.log(
'nwc',
`NWC client ${client} paid ${formatSats(spentMsat)} sats.`,
(error) => {
lastError.value = error.message;
},
);
} }
if (method === 'make_invoice' && changeset.add.length > 0) { if (method === 'make_invoice' && changeset.add.length > 0) {
const mintedMsat = changeset.add.reduce((sum, note) => sum + note.amount, 0); const mintedMsat = changeset.add.reduce((sum, note) => sum + note.amount, 0);
activity.log('nwc', `Received ${formatSats(mintedMsat)} sats via NWC client ${client}.`); await activity.log(
} 'nwc',
const onFailure = (error: unknown) => { `Received ${formatSats(mintedMsat)} sats via NWC client ${client}.`,
lastError.value = error instanceof Error ? error.message : 'Applying an NWC change failed.'; (error) => {
}; lastError.value = error.message;
if (changeset.add.length > 0) { },
void wallet.addBearers(changeset.add).catch(onFailure); );
}
for (const id of changeset.markSpent) {
void wallet.markSpent(id).catch(onFailure);
} }
}; };
@@ -115,15 +132,21 @@ export const useNwcStore = defineStore('nwc', () => {
// a start that is still in flight when stop (or a restart) lands. // a start that is still in flight when stop (or a restart) lands.
let service: NwcService | null = null; let service: NwcService | null = null;
let startToken = 0; let startToken = 0;
const pendingStarts = new Set<Promise<void>>();
let stopping: Promise<void> = Promise.resolve();
let pendingStop: Promise<void> | null = null;
const start = async (): Promise<void> => { const startNow = async (token: number): Promise<void> => {
const token = ++startToken; await stopping;
if (token !== startToken || wallet.state !== 'unlocked' || !enabled.value) return;
lastError.value = ''; lastError.value = '';
try { try {
const ownerFence = wallet.captureOwnerFence();
const started = await startService(wallet.requireLinkingKey(), { const started = await startService(wallet.requireLinkingKey(), {
// only spendable notes may back an NWC payment // only spendable notes may back an NWC payment
getBearers: () => wallet.unspentBearers, getBearers: () => wallet.unspentBearers,
getDefaultMint: () => mints.defaultMint, getDefaultMint: () => mints.defaultMint,
assertCurrentOwner: ownerFence,
applyChangeset, applyChangeset,
transport: transportOverride ?? undefined, transport: transportOverride ?? undefined,
onError: (error) => { onError: (error) => {
@@ -133,7 +156,7 @@ export const useNwcStore = defineStore('nwc', () => {
}); });
if (token !== startToken) { if (token !== startToken) {
// stopped (or restarted) while we were subscribing // stopped (or restarted) while we were subscribing
started.stop(); await started.stop();
return; return;
} }
service = started; service = started;
@@ -146,34 +169,74 @@ export const useNwcStore = defineStore('nwc', () => {
} }
}; };
const stop = (): void => { const start = (): Promise<void> => {
startToken++; pendingStop = null;
service?.stop(); const token = ++startToken;
const operation = startNow(token);
pendingStarts.add(operation);
void operation.then(
() => pendingStarts.delete(operation),
() => pendingStarts.delete(operation),
);
return operation;
};
const stop = (): Promise<void> => {
if (service === null && pendingStarts.size === 0 && pendingStop !== null) {
const result = pendingStop;
pendingStop = null;
return result;
}
startToken += 1;
const active = service;
service = null; service = null;
running.value = false; running.value = false;
const priorStop = stopping;
const activeStop = active?.stop() ?? Promise.resolve();
const completion = Promise.all([priorStop, activeStop, ...pendingStarts]).then(() => undefined);
pendingStop = completion;
stopping = completion.catch(() => undefined);
return completion;
}; };
watch( watch(
() => [wallet.state, enabled.value] as const, () => wallet.state,
([state, on]) => { (state) => {
if (state === 'unlocked' && on) void start(); void stop()
else stop(); .then(() => {
if (state !== 'unlocked') {
enabled.value = false;
connections.value = [];
return;
}
const ownerId = ownerFromWallet();
refresh(ownerId);
enabled.value = readNwcEnabled(ownerId);
if (enabled.value) return start();
})
.catch((error: unknown) => {
lastError.value =
error instanceof Error ? error.message : 'The NWC service failed to stop.';
});
}, },
{ immediate: true }, { immediate: true },
); );
// the served set is a startup snapshot, so any change to the connection // the served set is a startup snapshot, so any change to the connection
// records (create / budget edit / revoke) restarts the service to match // records (create / budget edit / revoke) restarts the service to match
const restartIfRunning = (): void => { const restartIfRunning = async (): Promise<void> => {
if (!running.value) return; if (!running.value) return;
stop(); await stop();
if (wallet.state === 'unlocked' && enabled.value) void start(); if (wallet.state === 'unlocked' && enabled.value) await start();
}; };
// ---- settings ---- // ---- settings ----
const setEnabled = (value: boolean): void => { const setEnabled = async (value: boolean): Promise<void> => {
const ownerId = ownerFromWallet();
writeNwcEnabled(ownerId, value);
enabled.value = value; enabled.value = value;
localStorage.setItem(NWC_ENABLED_KEY, String(value)); if (value) await start();
else await stop();
}; };
// ---- connection management ---- // ---- connection management ----
@@ -183,22 +246,24 @@ export const useNwcStore = defineStore('nwc', () => {
const create = (relays: string[], budget: NwcBudget): CreatedConnection => { const create = (relays: string[], budget: NwcBudget): CreatedConnection => {
const created = createConnection(wallet.requireLinkingKey(), { relays, budget }); const created = createConnection(wallet.requireLinkingKey(), { relays, budget });
refresh(); refresh();
restartIfRunning(); void restartIfRunning();
return created; return created;
}; };
const updateBudget = (clientPubkey: string, budget: NwcBudget): void => { const updateBudget = (clientPubkey: string, budget: NwcBudget): void => {
const record = readNwcConnections().find((r) => r.clientPubkey === clientPubkey); const ownerId = ownerFromWallet();
const record = readNwcConnections(ownerId).find((r) => r.clientPubkey === clientPubkey);
if (!record) return; if (!record) return;
persistNwcConnection({ ...record, budget }); persistNwcConnection(ownerId, { ...record, budget });
refresh(); refresh(ownerId);
restartIfRunning(); void restartIfRunning();
}; };
const revoke = (clientPubkey: string): void => { const revoke = (clientPubkey: string): void => {
removeNwcConnection(clientPubkey); const ownerId = ownerFromWallet();
refresh(); removeNwcConnection(ownerId, clientPubkey);
restartIfRunning(); refresh(ownerId);
void restartIfRunning();
}; };
return { return {
@@ -206,6 +271,7 @@ export const useNwcStore = defineStore('nwc', () => {
connections, connections,
running, running,
lastError, lastError,
stop,
setEnabled, setEnabled,
create, create,
updateBudget, updateBudget,
@@ -216,7 +282,7 @@ export const useNwcStore = defineStore('nwc', () => {
// dev-only e2e hook: lets a spec inject a fake relay transport before // dev-only e2e hook: lets a spec inject a fake relay transport before
// enabling the service, so the suite opens no real WebSocket // enabling the service, so the suite opens no real WebSocket
if (import.meta.env.DEV && typeof window !== 'undefined') { if (import.meta.env.DEV && typeof window !== 'undefined') {
(window as unknown as Record<string, unknown>).__sattleNwcTest = { window.__sattleNwcTest = {
setTransport: setNwcTransportForTests, setTransport: setNwcTransportForTests,
}; };
} }