From 04e62110b66c4be50f07a97aec48d3f68f6c9989 Mon Sep 17 00:00:00 2001 From: protom Date: Sat, 5 Sep 2026 11:44:29 +0200 Subject: [PATCH] fix(composables): commit carve checkpoints by output secret --- src/composables/walletCarveCommit.test.ts | 52 +++++++++++++++++++++-- src/composables/walletCarveCommit.ts | 35 +++++---------- 2 files changed, 59 insertions(+), 28 deletions(-) diff --git a/src/composables/walletCarveCommit.test.ts b/src/composables/walletCarveCommit.test.ts index eef9a71..abc42bc 100644 --- a/src/composables/walletCarveCommit.test.ts +++ b/src/composables/walletCarveCommit.test.ts @@ -5,6 +5,7 @@ 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 { FUNDS_STORAGE_KEY } from '@/lnurlcash/storage/bearers'; import { stubLocalStorage } from '@/lnurlcash/test-utils'; import type { NewBearer } from '@/lnurlcash/types'; import { commitCarve } from './walletCarveCommit'; @@ -21,7 +22,7 @@ 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') { + if (key === FUNDS_STORAGE_KEY) { writes += 1; 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(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); - vi.stubGlobal('navigator', {}); + stubPassthroughLocks(); stubLocalStorage(); setActivePinia(createPinia()); }); @@ -59,7 +68,7 @@ describe('commitCarve', () => { }); // 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(wallet.bearers).toHaveLength(3); 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 () => { // 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) const wallet = useWalletStore(); await wallet.create(); @@ -127,4 +136,39 @@ describe('commitCarve', () => { expect(persisted).toHaveLength(1); 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); + }); }); diff --git a/src/composables/walletCarveCommit.ts b/src/composables/walletCarveCommit.ts index ed9b032..1c5b817 100644 --- a/src/composables/walletCarveCommit.ts +++ b/src/composables/walletCarveCommit.ts @@ -1,5 +1,4 @@ import type { CarveResult } from '@/lnurlcash/ops'; -import type { BearerChangeset } from '@/lnurlcash/storage'; import type { Bearer, NewBearer } from '@/lnurlcash/types'; import { TrustedMintPostCommitError } from '@/stores/wallet'; import type { WalletOwnerFence } from '@/stores/walletOwnerFence'; @@ -7,10 +6,7 @@ import type { WalletOwnerFence } from '@/stores/walletOwnerFence'; export type CarveWallet = { readonly bearers: readonly Bearer[]; readonly addBearers: (notes: NewBearer[], ownerFence: WalletOwnerFence) => Promise; - readonly applyChangeset: ( - changeset: BearerChangeset, - ownerFence: WalletOwnerFence, - ) => Promise; + readonly commitCarve: (carve: CarveResult, ownerFence: WalletOwnerFence) => Promise; }; type CarveCommitContext = Readonly<{ @@ -35,33 +31,24 @@ export const addCommittedBearers = async ( // 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 // 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 -// single changeset through the wallet's one-write boundary, never an -// add-then-markSpent sequence of separate writes. +// but not spent, or vice versa) would strand or double-show money. Both +// checkpoint phases (pre-wire staging, landed retirement) go through the +// 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 ( wallet: CarveWallet, carve: CarveResult, context: CarveCommitContext, ): Promise => { - 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 { - added = await wallet.applyChangeset( - { - add: additions, - markSpent: carve.consumed.map((bearer) => bearer.id), - }, - context.ownerFence, - ); + return await wallet.commitCarve(carve, context.ownerFence); } catch (error) { if (!(error instanceof TrustedMintPostCommitError)) throw error; 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; };