feat: apply wallet fund changes atomically

This commit is contained in:
2026-08-22 16:56:11 +02:00
parent 9fa4abdaf3
commit 5b3a69e910
2 changed files with 311 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
import { createPinia, setActivePinia } from 'pinia';
import { buildNoteUrl } from 'lnurlcash-kit';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { deriveBearerAesKey } from '@/lnurlcash/keys';
import { loadBearers } from '@/lnurlcash/storage';
import { stubLocalStorage } from '@/lnurlcash/test-utils';
import type { Bearer, NewBearer } from '@/lnurlcash/types';
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,
});
const rejectSecondEncryption = (): void => {
const encrypt = crypto.subtle.encrypt.bind(crypto.subtle);
let encryptions = 0;
vi.spyOn(crypto.subtle, 'encrypt').mockImplementation((algorithm, key, data) => {
encryptions += 1;
return encryptions === 2
? Promise.reject(new Error('second encryption failed'))
: encrypt(algorithm, key, data);
});
};
beforeEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.stubGlobal('navigator', {});
stubLocalStorage();
setActivePinia(createPinia());
});
describe('wallet multi-bearer durability', () => {
it('adds two bearers with one established changeset write', async () => {
const storage = stubLocalStorage();
const wallet = useWalletStore();
await wallet.create();
const writes = vi.spyOn(storage, 'setItem');
await wallet.addBearers([note('a'), note('b')], wallet.captureOwnerFence());
expect(writes.mock.calls.filter(([key]) => key === 'sattle_bearers')).toHaveLength(1);
expect(wallet.bearers).toHaveLength(2);
});
it('persists no partial addition when the second bearer encryption fails', async () => {
const wallet = useWalletStore();
await wallet.create();
rejectSecondEncryption();
await expect(
wallet.addBearers([note('a'), note('b')], wallet.captureOwnerFence()),
).rejects.toThrow('second encryption failed');
expect(wallet.bearers).toEqual([]);
const key = await deriveBearerAesKey(wallet.requireLinkingKey());
expect(await loadBearers(key)).toEqual([]);
});
it('persists no partial external merge when the second encryption fails', async () => {
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 incoming: Bearer[] = [
{
id: 'incoming-b',
...note('b'),
createdAt: Date.now() + 1,
updatedAt: Date.now() + 1,
},
{
id: 'incoming-c',
...note('c'),
createdAt: Date.now() + 2,
updatedAt: Date.now() + 2,
},
];
rejectSecondEncryption();
await expect(wallet.mergeExternalBearers(incoming, wallet.captureOwnerFence())).rejects.toThrow(
'second encryption failed',
);
expect(wallet.bearers).toEqual([existing]);
const key = await deriveBearerAesKey(wallet.requireLinkingKey());
expect(await loadBearers(key)).toEqual([existing]);
});
it('merges multiple external bearers with one established changeset write', async () => {
const storage = stubLocalStorage();
const wallet = useWalletStore();
await wallet.create();
await wallet.addBearers([note('a')], wallet.captureOwnerFence());
const now = Date.now();
const incoming: Bearer[] = [
{ id: 'incoming-b', ...note('b'), createdAt: now + 1, updatedAt: now + 1 },
{ id: 'incoming-c', ...note('c'), createdAt: now + 2, updatedAt: now + 2 },
];
const writes = vi.spyOn(storage, 'setItem');
await wallet.mergeExternalBearers(incoming, wallet.captureOwnerFence());
expect(writes.mock.calls.filter(([key]) => key === 'sattle_bearers')).toHaveLength(1);
expect(wallet.bearers).toHaveLength(3);
});
});
+200
View File
@@ -0,0 +1,200 @@
import { computed, ref } from 'vue';
import { serverOf } from 'lnurlcash-kit';
import {
applyBearerChangeset,
deleteBearerRecord,
loadBearers,
mergeBearers,
persistBearer,
} from '@/lnurlcash/storage';
import type { BearerChangeset } from '@/lnurlcash/storage';
import { lockTrustedMint } from '@/lnurlcash/trustedMints';
import type { Bearer, NewBearer } from '@/lnurlcash/types';
import { msatToSats } from '@/lnurlcash/units';
import type { WalletOwnerFence } from './walletOwnerFence';
export class TrustedMintPostCommitError extends Error {
override readonly name = 'TrustedMintPostCommitError';
readonly fundsCommitted = true;
constructor(
readonly committedBearers: Bearer[],
options: { cause: unknown },
) {
super(
'Funds were saved, but the trusted-mint registry could not be updated. The receive succeeded; do not retry it.',
options,
);
}
}
type WalletFundsOptions = {
readonly requireKey: () => CryptoKey;
readonly ownerId: () => string | undefined;
readonly setAuxiliaryError: (message: string) => void;
};
export const createWalletFunds = (options: WalletFundsOptions) => {
const bearers = ref<Bearer[]>([]);
const unspentBearers = computed(() => bearers.value.filter((bearer) => !bearer.spent));
const balanceMsat = computed(() =>
unspentBearers.value.reduce((sum, bearer) => sum + bearer.amount, 0),
);
const balanceSats = computed(() => msatToSats(balanceMsat.value));
const balanceByMintMsat = computed(() => {
const byMint = new Map<string, number>();
for (const bearer of unspentBearers.value) {
const server = serverOf(bearer.url);
byMint.set(server, (byMint.get(server) ?? 0) + bearer.amount);
}
return byMint;
});
const balanceByMintSats = computed(() => {
const byMint = new Map<string, number>();
for (const [server, msat] of balanceByMintMsat.value) {
byMint.set(server, msatToSats(msat));
}
return byMint;
});
const replace = (loaded: Bearer[]): void => {
bearers.value = loaded;
};
const clear = (): void => {
bearers.value = [];
};
const lockCommittedBearers = async (committed: Bearer[]): Promise<void> => {
try {
for (const bearer of committed) {
if (bearer.mintPubkey) {
await lockTrustedMint(serverOf(bearer.url), bearer.mintPubkey, options.ownerId());
}
}
} catch (error) {
const cause = error instanceof Error ? error : new Error('Trusted-mint update failed.');
const postCommitError = new TrustedMintPostCommitError(committed, { cause });
options.setAuxiliaryError(postCommitError.message);
throw postCommitError;
}
};
const addBearers = async (
notes: NewBearer[],
ownerFence: WalletOwnerFence,
): Promise<Bearer[]> => {
options.setAuxiliaryError('');
ownerFence();
const next = await applyBearerChangeset(
options.requireKey(),
bearers.value,
{ add: notes, markSpent: [] },
// re-prove ownership inside the lock: encryption is async, so the
// entry check alone would leave a cross-tab replacement window open
{ beforeCommit: ownerFence },
);
const added = next.slice(0, notes.length);
bearers.value = next;
await lockCommittedBearers(added);
return added;
};
const applyChangeset = async (
changeset: BearerChangeset,
ownerFence: WalletOwnerFence,
): Promise<Bearer[]> => {
options.setAuxiliaryError('');
ownerFence();
const next = await applyBearerChangeset(options.requireKey(), bearers.value, changeset, {
beforeCommit: ownerFence,
});
const added = next.slice(0, changeset.add.length);
bearers.value = next;
await lockCommittedBearers(added);
return added;
};
const updateBearer = async (
id: string,
changes: Partial<Omit<Bearer, 'id'>>,
ownerFence: WalletOwnerFence,
): Promise<void> => {
options.setAuxiliaryError('');
const current = bearers.value.find((bearer) => bearer.id === id);
if (!current) return;
ownerFence();
const updated: Bearer = { ...current, ...changes, updatedAt: Date.now() };
await persistBearer(options.requireKey(), updated, { beforeCommit: ownerFence });
bearers.value = bearers.value.map((bearer) => (bearer.id === id ? updated : bearer));
if (!updated.mintPubkey) return;
try {
await lockTrustedMint(serverOf(updated.url), updated.mintPubkey, options.ownerId());
} catch (error) {
const cause = error instanceof Error ? error : new Error('Trusted-mint update failed.');
const postCommitError = new TrustedMintPostCommitError([updated], { cause });
options.setAuxiliaryError(postCommitError.message);
throw postCommitError;
}
};
const markSpent = async (
id: string,
ownerFence: WalletOwnerFence,
spent = true,
): Promise<void> => {
await updateBearer(id, { spent }, ownerFence);
};
const removeNote = async (id: string, ownerFence: WalletOwnerFence): Promise<void> => {
ownerFence();
await deleteBearerRecord(id, { beforeCommit: ownerFence });
bearers.value = bearers.value.filter((bearer) => bearer.id !== id);
};
const mergeExternalBearers = async (
incoming: Bearer[],
ownerFence: WalletOwnerFence,
): Promise<void> => {
ownerFence();
const merged = mergeBearers(bearers.value, incoming);
const mergedIds = new Set(merged.map((bearer) => bearer.id));
await applyBearerChangeset(
options.requireKey(),
bearers.value,
{
add: [],
markSpent: [],
upsert: merged,
remove: bearers.value.filter((bearer) => !mergedIds.has(bearer.id)).map(({ id }) => id),
},
{ beforeCommit: ownerFence },
);
bearers.value = merged;
};
const reloadBearers = async (): Promise<void> => {
bearers.value = await loadBearers(options.requireKey());
};
return {
public: {
bearers,
unspentBearers,
balanceMsat,
balanceSats,
balanceByMintMsat,
balanceByMintSats,
addBearers,
applyChangeset,
updateBearer,
markSpent,
removeNote,
mergeExternalBearers,
reloadBearers,
},
replace,
clear,
};
};