mirror of
https://github.com/tompro/sattle.git
synced 2026-08-26 23:05:58 +00:00
feat: apply wallet fund changes atomically
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user