fix(composables): commit carve checkpoints by output secret

This commit is contained in:
2026-09-05 11:44:29 +02:00
parent 29830dc9e3
commit 04e62110b6
2 changed files with 59 additions and 28 deletions
+48 -4
View File
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { deriveBearerAesKey } from '@/lnurlcash/keys'; import { deriveBearerAesKey } from '@/lnurlcash/keys';
import type { CarveResult } from '@/lnurlcash/ops'; import type { CarveResult } from '@/lnurlcash/ops';
import { loadBearers } from '@/lnurlcash/storage'; import { loadBearers } from '@/lnurlcash/storage';
import { FUNDS_STORAGE_KEY } from '@/lnurlcash/storage/bearers';
import { stubLocalStorage } from '@/lnurlcash/test-utils'; import { stubLocalStorage } from '@/lnurlcash/test-utils';
import type { NewBearer } from '@/lnurlcash/types'; import type { NewBearer } from '@/lnurlcash/types';
import { commitCarve } from './walletCarveCommit'; import { commitCarve } from './walletCarveCommit';
@@ -21,7 +22,7 @@ const failOnBearerWrite = (occurrence: number): void => {
const setItem = localStorage.setItem.bind(localStorage); const setItem = localStorage.setItem.bind(localStorage);
let writes = 0; let writes = 0;
vi.spyOn(localStorage, 'setItem').mockImplementation((key, value) => { vi.spyOn(localStorage, 'setItem').mockImplementation((key, value) => {
if (key === 'sattle_bearers') { if (key === FUNDS_STORAGE_KEY) {
writes += 1; writes += 1;
if (writes === occurrence) throw new Error('bearer storage unavailable'); if (writes === occurrence) throw new Error('bearer storage unavailable');
} }
@@ -29,10 +30,18 @@ const failOnBearerWrite = (occurrence: number): void => {
}); });
}; };
// a present-but-non-serializing LockManager fake: walletFunds serializes its
// own mutations, so single-store tests only need locks to EXIST
const stubPassthroughLocks = (): void => {
vi.stubGlobal('navigator', {
locks: { request: (_name: string, fn: () => unknown) => Promise.resolve().then(fn) },
});
};
beforeEach(() => { beforeEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
vi.stubGlobal('navigator', {}); stubPassthroughLocks();
stubLocalStorage(); stubLocalStorage();
setActivePinia(createPinia()); setActivePinia(createPinia());
}); });
@@ -59,7 +68,7 @@ describe('commitCarve', () => {
}); });
// Then the whole rotation landed as ONE durable write // Then the whole rotation landed as ONE durable write
expect(writes.mock.calls.filter(([key]) => key === 'sattle_bearers')).toHaveLength(1); expect(writes.mock.calls.filter(([key]) => key === FUNDS_STORAGE_KEY)).toHaveLength(1);
expect(committed.url).toBe(carve.note.url); expect(committed.url).toBe(carve.note.url);
expect(wallet.bearers).toHaveLength(3); expect(wallet.bearers).toHaveLength(3);
expect(wallet.bearers.find((bearer) => bearer.id === input.id)?.spent).toBe(true); expect(wallet.bearers.find((bearer) => bearer.id === input.id)?.spent).toBe(true);
@@ -71,7 +80,7 @@ describe('commitCarve', () => {
it('survives a failure that would have hit the old split commit second write', async () => { 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 // Given a wallet holding the carve's input note, with the second
// sattle_bearers write poisoned (the old add-then-markSpent split wrote // funds-document write poisoned (the old add-then-markSpent split wrote
// twice; the single-write commit never reaches a second write) // twice; the single-write commit never reaches a second write)
const wallet = useWalletStore(); const wallet = useWalletStore();
await wallet.create(); await wallet.create();
@@ -127,4 +136,39 @@ describe('commitCarve', () => {
expect(persisted).toHaveLength(1); expect(persisted).toHaveLength(1);
expect(persisted[0]?.spent).toBeUndefined(); expect(persisted[0]?.spent).toBeUndefined();
}); });
it('commits the staged and landed phases of the same output as one record', 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 context = { ownerFence, warn: () => undefined };
// When the pre-wire phase stages the outputs (sig-less URLs), then the
// landed phase re-reports them with the mint's signature in the URL
await commitCarve(wallet, { note: note('bb'), change: note('cc'), consumed: [] }, context);
expect(wallet.bearers).toHaveLength(3);
const stagedNote = note('bb');
const committed = await commitCarve(
wallet,
{
note: { ...stagedNote, url: `${stagedNote.url}&sig=${'ab'.repeat(64)}`, verified: true },
consumed: [input],
},
context,
);
// Then the staged record was refreshed in place: still one record per
// output, the input retired, no duplicate from the URL difference
expect(wallet.bearers).toHaveLength(3);
expect(committed.verified).toBe(true);
expect(wallet.bearers.find((bearer) => bearer.id === input.id)?.spent).toBe(true);
expect(
wallet.bearers.filter((bearer) => bearer.url.includes('bb'.repeat(32))),
).toHaveLength(1);
const key = await deriveBearerAesKey(wallet.requireLinkingKey());
expect(await loadBearers(key)).toHaveLength(3);
});
}); });
+11 -24
View File
@@ -1,5 +1,4 @@
import type { CarveResult } from '@/lnurlcash/ops'; import type { CarveResult } from '@/lnurlcash/ops';
import type { BearerChangeset } from '@/lnurlcash/storage';
import type { Bearer, NewBearer } from '@/lnurlcash/types'; import type { Bearer, NewBearer } from '@/lnurlcash/types';
import { TrustedMintPostCommitError } from '@/stores/wallet'; import { TrustedMintPostCommitError } from '@/stores/wallet';
import type { WalletOwnerFence } from '@/stores/walletOwnerFence'; import type { WalletOwnerFence } from '@/stores/walletOwnerFence';
@@ -7,10 +6,7 @@ import type { WalletOwnerFence } from '@/stores/walletOwnerFence';
export type CarveWallet = { export type CarveWallet = {
readonly bearers: readonly Bearer[]; readonly bearers: readonly Bearer[];
readonly addBearers: (notes: NewBearer[], ownerFence: WalletOwnerFence) => Promise<Bearer[]>; readonly addBearers: (notes: NewBearer[], ownerFence: WalletOwnerFence) => Promise<Bearer[]>;
readonly applyChangeset: ( readonly commitCarve: (carve: CarveResult, ownerFence: WalletOwnerFence) => Promise<Bearer>;
changeset: BearerChangeset,
ownerFence: WalletOwnerFence,
) => Promise<Bearer[]>;
}; };
type CarveCommitContext = Readonly<{ type CarveCommitContext = Readonly<{
@@ -35,33 +31,24 @@ export const addCommittedBearers = async (
// A carve is ONE logical rotation: the fresh notes (target + change) and the // A carve is ONE logical rotation: the fresh notes (target + change) and the
// spent marks of the burned inputs must land together or not at all - the // spent marks of the burned inputs must land together or not at all - the
// mint already destroyed the inputs server-side, so a partial commit (added // mint already destroyed the inputs server-side, so a partial commit (added
// but not spent, or vice versa) would strand or double-show money. Hence a // but not spent, or vice versa) would strand or double-show money. Both
// single changeset through the wallet's one-write boundary, never an // checkpoint phases (pre-wire staging, landed retirement) go through the
// add-then-markSpent sequence of separate writes. // wallet's k1-aware one-write boundary: the landed phase's URLs carry the
// mint's signature the staged phase's could not have, so the store matches
// checkpoints by output SECRET and refreshes the staged record in place -
// a replayed checkpoint is a no-op, never a duplicate.
export const commitCarve = async ( export const commitCarve = async (
wallet: CarveWallet, wallet: CarveWallet,
carve: CarveResult, carve: CarveResult,
context: CarveCommitContext, context: CarveCommitContext,
): Promise<Bearer> => { ): Promise<Bearer> => {
const existing = wallet.bearers.find((bearer) => bearer.url === carve.note.url);
const additions: NewBearer[] = [];
if (!existing) additions.push(carve.note);
if (carve.change) additions.push(carve.change);
let added: Bearer[];
try { try {
added = await wallet.applyChangeset( return await wallet.commitCarve(carve, context.ownerFence);
{
add: additions,
markSpent: carve.consumed.map((bearer) => bearer.id),
},
context.ownerFence,
);
} catch (error) { } catch (error) {
if (!(error instanceof TrustedMintPostCommitError)) throw error; if (!(error instanceof TrustedMintPostCommitError)) throw error;
context.warn(error.message); context.warn(error.message);
added = error.committedBearers; const committed = error.committedBearers[0];
if (!committed) throw error;
return committed;
} }
const committed = existing ?? added[0];
if (!committed) throw new Error('The carved note was not tracked.');
return committed;
}; };