mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
fix: await wallet commits in the NWC store
This commit is contained in:
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user