feat(wallet): allocate operation secrets from the cash root

This commit is contained in:
2026-09-05 11:33:42 +02:00
parent 3876791a0b
commit e6d0712258
4 changed files with 172 additions and 4 deletions
+3
View File
@@ -1,6 +1,7 @@
// allow: SIZE_OK — indivisible wallet lifecycle state machine and its public Pinia surface. // allow: SIZE_OK — indivisible wallet lifecycle state machine and its public Pinia surface.
import { computed, onScopeDispose, ref } from 'vue'; import { computed, onScopeDispose, ref } from 'vue';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { cashNodeFromHex } from 'lnurlcash-kit';
import { import {
clearSavedLinkingKey, clearSavedLinkingKey,
@@ -176,6 +177,7 @@ export const useWalletStore = defineStore('wallet', () => {
funds.replace(loaded); funds.replace(loaded);
acceptingOwnerWork = true; acceptingOwnerWork = true;
state.value = 'unlocked'; state.value = 'unlocked';
await funds.public.recoverPendingMints(ownerFence.capture());
idleWatch.start(); idleWatch.start();
} catch (error) { } catch (error) {
stopOwnerChanges(); stopOwnerChanges();
@@ -323,6 +325,7 @@ export const useWalletStore = defineStore('wallet', () => {
const funds = createWalletFunds({ const funds = createWalletFunds({
requireKey, requireKey,
requireCashRoot: () => cashNodeFromHex(requireWalletMaterial().cashRootHex),
ownerId: () => pubkey.value ?? undefined, ownerId: () => pubkey.value ?? undefined,
setAuxiliaryError: (message) => { setAuxiliaryError: (message) => {
auxiliaryError.value = message; auxiliaryError.value = message;
+20 -1
View File
@@ -1,5 +1,5 @@
import { createPinia, setActivePinia } from 'pinia'; import { createPinia, setActivePinia } from 'pinia';
import { buildNoteUrl, deriveCashRoot, deriveCashSecret } from 'lnurlcash-kit'; import { buildNoteUrl, cashNodeFromHex, deriveCashRoot, deriveCashSecret } from 'lnurlcash-kit';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { deriveBearerAesKey } from '@/lnurlcash/keys'; import { deriveBearerAesKey } from '@/lnurlcash/keys';
@@ -241,4 +241,23 @@ describe('wallet BIP-32 counter allocation', () => {
expect(encrypt).not.toHaveBeenCalled(); expect(encrypt).not.toHaveBeenCalled();
expect(readFundsDocument().nextByHost).toEqual({}); expect(readFundsDocument().nextByHost).toEqual({});
}); });
it('serves the operation-facing allocation from the unlocked wallet material', async () => {
const wallet = useWalletStore();
await wallet.create();
const root = cashNodeFromHex(wallet.requireWalletMaterial().cashRootHex);
const secrets = await wallet.allocateOutputSecrets(
'mint.example',
2,
wallet.captureOwnerFence(),
);
expect(secrets).toEqual([
deriveCashSecret(root, 'mint.example', 0),
deriveCashSecret(root, 'mint.example', 1),
]);
expect(readFundsDocument().nextByHost).toEqual({ 'mint.example': 2 });
expect(readFundsDocument().pending).toHaveLength(1);
});
}); });
+81 -2
View File
@@ -1,8 +1,10 @@
import { createPinia, setActivePinia } from 'pinia'; import { createPinia, setActivePinia } from 'pinia';
import { buildNoteUrl } from 'lnurlcash-kit'; import { buildNoteUrl, cashNodeFromHex } from 'lnurlcash-kit';
import { createMockMint } from 'lnurlcash-conformance/mock-mint'; import { createMockMint } from 'lnurlcash-conformance/mock-mint';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { readPendingJournal } from '@/lnurlcash/storage';
import { readFundsDocument } 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 { useWalletStore } from './wallet'; import { useWalletStore } from './wallet';
@@ -329,9 +331,9 @@ describe('pending mint recovery', () => {
it('ends recovery when the staged target is definitively spent', async () => { it('ends recovery when the staged target is definitively spent', async () => {
const instance = await mint(); const instance = await mint();
const secret = '1'.repeat(64);
const wallet = useWalletStore(); const wallet = useWalletStore();
await wallet.create(); await wallet.create();
const secret = '1'.repeat(64);
const [staged] = await wallet.addBearers( const [staged] = await wallet.addBearers(
[stagedNote(instance, secret)], [stagedNote(instance, secret)],
wallet.captureOwnerFence(), wallet.captureOwnerFence(),
@@ -347,4 +349,81 @@ describe('pending mint recovery', () => {
pendingMint: undefined, pendingMint: undefined,
}); });
}); });
it('finalizes a landed receive rotation under the staged record id', async () => {
const instance = await mint();
const wallet = useWalletStore();
await wallet.create();
const originalK1 = '1c'.repeat(32);
const rotationSecret = '1d'.repeat(32);
// the rotate landed before the crash: the mint credited the staged secret
instance.state.creditNote(rotationSecret, 21_000);
const [staged] = await wallet.addBearers(
[
{
url: buildNoteUrl(`${instance.url}/w`, rotationSecret, 21_000),
callback: `${instance.url}/w/cb`,
amount: 21_000,
verified: false,
pendingMint: { sourceRecoverySecret: originalK1 },
},
],
wallet.captureOwnerFence(),
);
if (!staged) throw new Error('Expected a staged bearer.');
await wallet.recoverPendingMints(wallet.captureOwnerFence());
expect(wallet.bearers.find((bearer) => bearer.id === staged.id)).toMatchObject({
amount: 21_000,
verified: true,
pendingMint: undefined,
});
expect(wallet.balanceMsat).toBe(21_000);
});
it('restores a received note at its original secret when the rotation never landed', async () => {
const instance = await mint();
const wallet = useWalletStore();
await wallet.create();
const originalK1 = '1e'.repeat(32);
const rotationSecret = '1f'.repeat(32);
// the rotate never reached the mint: the sender's copy is still live
instance.state.creditNote(originalK1, 21_000);
const [staged] = await wallet.addBearers(
[
{
url: buildNoteUrl(`${instance.url}/w`, rotationSecret, 21_000),
callback: `${instance.url}/w/cb`,
amount: 21_000,
verified: false,
pendingMint: { sourceRecoverySecret: originalK1 },
},
],
wallet.captureOwnerFence(),
);
if (!staged) throw new Error('Expected a staged bearer.');
await wallet.recoverPendingMints(wallet.captureOwnerFence());
const restored = wallet.bearers.find((bearer) => bearer.id === staged.id);
expect(restored?.url).toContain(originalK1);
expect(restored).toMatchObject({ amount: 21_000, verified: true, pendingMint: undefined });
expect(wallet.balanceMsat).toBe(21_000);
expect(instance.state.noteState(originalK1)).toBe('outstanding');
});
it('retires cash-allocation journal records once the bearer side is reconciled', async () => {
const wallet = useWalletStore();
await wallet.create();
const root = cashNodeFromHex(wallet.requireWalletMaterial().cashRootHex);
await wallet.allocateCashSecrets(root, 'mint.example', 2, wallet.captureOwnerFence());
expect(readPendingJournal()).toHaveLength(1);
await wallet.recoverPendingMints(wallet.captureOwnerFence());
// the journal record is retired; the reserved counter range never rewinds
expect(readPendingJournal()).toEqual([]);
expect(readFundsDocument().nextByHost).toEqual({ 'mint.example': 2 });
});
}); });
+68 -1
View File
@@ -1,6 +1,6 @@
// allow: SIZE_OK — cohesive encrypted-bearer repository with one serialized mutation boundary. // allow: SIZE_OK — cohesive encrypted-bearer repository with one serialized mutation boundary.
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { deriveCashSecret, noteK1, serverOf } from 'lnurlcash-kit'; import { deriveCashSecret, noteK1, serverOf, withNewK1 } from 'lnurlcash-kit';
import type { CashNode } from 'lnurlcash-kit'; import type { CashNode } from 'lnurlcash-kit';
import { import {
@@ -9,6 +9,7 @@ import {
mergeBearers, mergeBearers,
persistBearer, persistBearer,
readFundsRevision, readFundsRevision,
readPendingJournal,
reserveCashIndices, reserveCashIndices,
} from '@/lnurlcash/storage'; } from '@/lnurlcash/storage';
import type { BearerChangeset } from '@/lnurlcash/storage'; import type { BearerChangeset } from '@/lnurlcash/storage';
@@ -55,6 +56,7 @@ export type AllocatedCashSecrets = {
type WalletFundsOptions = { type WalletFundsOptions = {
readonly requireKey: () => CryptoKey; readonly requireKey: () => CryptoKey;
readonly requireCashRoot: () => CashNode;
readonly ownerId: () => string | undefined; readonly ownerId: () => string | undefined;
readonly setAuxiliaryError: (message: string) => void; readonly setAuxiliaryError: (message: string) => void;
}; };
@@ -409,6 +411,33 @@ export const createWalletFunds = (options: WalletFundsOptions) => {
await removeNote(staged.id, ownerFence); await removeNote(staged.id, ownerFence);
break; break;
} }
if (
!staged.pendingMint.sourceBearerId &&
staged.pendingMint.sourceRecoverySecret
) {
// a receive rotation that never landed: nothing exists at
// the staged secret, so the note is restored at the
// original k1 it was received on (journal GC below retires
// the allocation record separately)
await finalizeStagedMintOutput(
staged.id,
{
url: withNewK1(
staged.url,
staged.pendingMint.sourceRecoverySecret,
staged.amount,
),
callback: staged.callback,
amount: staged.amount,
verified: true,
...(staged.pendingMint.mintPubkey
? { mintPubkey: staged.pendingMint.mintPubkey }
: {}),
},
ownerFence,
);
break;
}
await recoverLinkedSource(); await recoverLinkedSource();
break; break;
case 'pending': case 'pending':
@@ -451,6 +480,28 @@ export const createWalletFunds = (options: WalletFundsOptions) => {
); );
} }
} }
// Retire cash-allocation journal records: their secrets are either
// already carried by staged bearer records (the operative journal)
// or never reached a mint (staging always precedes the wire), so the
// records are crash-window proofs, safe to clear once recovery has
// reconciled the bearer side. The counter bump they document is
// permanent either way - burned indices are never reused.
try {
const staleAllocationIds = readPendingJournal()
.filter((record) => record.kind === 'cash-allocation')
.map((record) => record.id);
if (staleAllocationIds.length > 0) {
await applyChangeset(
{ add: [], markSpent: [], clearPending: staleAllocationIds },
ownerFence,
);
}
} catch (error) {
ownerFence();
options.setAuxiliaryError(
'Pending allocation records could not be cleared. They will be retired later.',
);
}
})().finally(() => { })().finally(() => {
recoveryRun = null; recoveryRun = null;
}); });
@@ -535,6 +586,21 @@ export const createWalletFunds = (options: WalletFundsOptions) => {
}; };
}); });
// The operation-facing allocation path: the ops engine asks for exactly
// the output secrets it is about to stage, keyed by the mint's canonical
// host, and the reservation (counter bump + encrypted journal record)
// commits before any of them can reach the wire. The cash root never
// leaves the wallet store - it is re-read from the unlocked material per
// call.
const allocateOutputSecrets = (
server: string,
count: number,
ownerFence: WalletOwnerFence,
): Promise<readonly string[]> =>
allocateCashSecrets(options.requireCashRoot(), server, count, ownerFence).then(
(allocated) => allocated.secrets,
);
return { return {
public: { public: {
bearers, bearers,
@@ -554,6 +620,7 @@ export const createWalletFunds = (options: WalletFundsOptions) => {
finalizeSpentStagedMintOutput, finalizeSpentStagedMintOutput,
recoverPendingMints, recoverPendingMints,
allocateCashSecrets, allocateCashSecrets,
allocateOutputSecrets,
markSpent, markSpent,
removeNote, removeNote,
mergeExternalBearers, mergeExternalBearers,