mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
fix(composables): commit carve checkpoints by output secret
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
const committed = existing ?? added[0];
|
|
||||||
if (!committed) throw new Error('The carved note was not tracked.');
|
|
||||||
return committed;
|
return committed;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user