mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
fix: commit carved wallet funds atomically
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { buildNoteUrl } from 'lnurlcash-kit';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { deriveBearerAesKey } from '@/lnurlcash/keys';
|
||||
import type { CarveResult } from '@/lnurlcash/ops';
|
||||
import { loadBearers } from '@/lnurlcash/storage';
|
||||
import { stubLocalStorage } from '@/lnurlcash/test-utils';
|
||||
import type { NewBearer } from '@/lnurlcash/types';
|
||||
import { commitCarve } from './walletCarveCommit';
|
||||
import { useWalletStore } from '../stores/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 failOnBearerWrite = (occurrence: number): void => {
|
||||
const setItem = localStorage.setItem.bind(localStorage);
|
||||
let writes = 0;
|
||||
vi.spyOn(localStorage, 'setItem').mockImplementation((key, value) => {
|
||||
if (key === 'sattle_bearers') {
|
||||
writes += 1;
|
||||
if (writes === occurrence) throw new Error('bearer storage unavailable');
|
||||
}
|
||||
setItem(key, value);
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.stubGlobal('navigator', {});
|
||||
stubLocalStorage();
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
describe('commitCarve', () => {
|
||||
it('commits the carve additions and spent marks in one bearer write', async () => {
|
||||
// Given a wallet holding the carve's input note
|
||||
const wallet = useWalletStore();
|
||||
await wallet.create();
|
||||
const ownerFence = wallet.captureOwnerFence();
|
||||
const [input] = await wallet.addBearers([note('aa')], ownerFence);
|
||||
if (!input) throw new Error('Expected the input bearer.');
|
||||
const carve: CarveResult = {
|
||||
note: note('bb'),
|
||||
change: note('cc'),
|
||||
consumed: [input],
|
||||
};
|
||||
const writes = vi.spyOn(localStorage, 'setItem');
|
||||
|
||||
// When the carve is committed
|
||||
const committed = await commitCarve(wallet, carve, {
|
||||
ownerFence,
|
||||
warn: () => undefined,
|
||||
});
|
||||
|
||||
// Then the whole rotation landed as ONE durable write
|
||||
expect(writes.mock.calls.filter(([key]) => key === 'sattle_bearers')).toHaveLength(1);
|
||||
expect(committed.url).toBe(carve.note.url);
|
||||
expect(wallet.bearers).toHaveLength(3);
|
||||
expect(wallet.bearers.find((bearer) => bearer.id === input.id)?.spent).toBe(true);
|
||||
const key = await deriveBearerAesKey(wallet.requireLinkingKey());
|
||||
const persisted = await loadBearers(key);
|
||||
expect(persisted).toHaveLength(3);
|
||||
expect(persisted.find((bearer) => bearer.id === input.id)?.spent).toBe(true);
|
||||
});
|
||||
|
||||
it('survives a failure that would have hit the old split commit second write', async () => {
|
||||
// Given a wallet holding the carve's input note, with the second
|
||||
// sattle_bearers write poisoned (the old add-then-markSpent split wrote
|
||||
// twice; the single-write commit never reaches a second write)
|
||||
const wallet = useWalletStore();
|
||||
await wallet.create();
|
||||
const ownerFence = wallet.captureOwnerFence();
|
||||
const [input] = await wallet.addBearers([note('aa')], ownerFence);
|
||||
if (!input) throw new Error('Expected the input bearer.');
|
||||
failOnBearerWrite(2);
|
||||
const carve: CarveResult = {
|
||||
note: note('bb'),
|
||||
change: note('cc'),
|
||||
consumed: [input],
|
||||
};
|
||||
|
||||
// When the carve is committed
|
||||
const committed = await commitCarve(wallet, carve, {
|
||||
ownerFence,
|
||||
warn: () => undefined,
|
||||
});
|
||||
|
||||
// Then the rotation committed completely: additions tracked, input spent
|
||||
expect(committed.url).toBe(carve.note.url);
|
||||
expect(wallet.bearers).toHaveLength(3);
|
||||
expect(wallet.bearers.find((bearer) => bearer.id === input.id)?.spent).toBe(true);
|
||||
const key = await deriveBearerAesKey(wallet.requireLinkingKey());
|
||||
const persisted = await loadBearers(key);
|
||||
expect(persisted).toHaveLength(3);
|
||||
expect(persisted.find((bearer) => bearer.id === input.id)?.spent).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves no partial carve behind when the commit write itself fails', async () => {
|
||||
// Given bearer storage that fails the very next write
|
||||
const wallet = useWalletStore();
|
||||
await wallet.create();
|
||||
const ownerFence = wallet.captureOwnerFence();
|
||||
const [input] = await wallet.addBearers([note('aa')], ownerFence);
|
||||
if (!input) throw new Error('Expected the input bearer.');
|
||||
failOnBearerWrite(1);
|
||||
|
||||
// When the carve commit fails
|
||||
await expect(
|
||||
commitCarve(
|
||||
wallet,
|
||||
{ note: note('bb'), change: note('cc'), consumed: [input] },
|
||||
{ ownerFence, warn: () => undefined },
|
||||
),
|
||||
).rejects.toThrow('bearer storage unavailable');
|
||||
|
||||
// Then nothing moved: not in storage, not in the reactive list
|
||||
expect(wallet.bearers).toHaveLength(1);
|
||||
expect(wallet.bearers[0]?.spent).toBeUndefined();
|
||||
const key = await deriveBearerAesKey(wallet.requireLinkingKey());
|
||||
const persisted = await loadBearers(key);
|
||||
expect(persisted).toHaveLength(1);
|
||||
expect(persisted[0]?.spent).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user