diff --git a/src/stores/wallet.ts b/src/stores/wallet.ts index 7463dc7..f31106d 100644 --- a/src/stores/wallet.ts +++ b/src/stores/wallet.ts @@ -1,6 +1,7 @@ // allow: SIZE_OK — indivisible wallet lifecycle state machine and its public Pinia surface. import { computed, onScopeDispose, ref } from 'vue'; import { defineStore } from 'pinia'; +import { cashNodeFromHex } from 'lnurlcash-kit'; import { clearSavedLinkingKey, @@ -176,6 +177,7 @@ export const useWalletStore = defineStore('wallet', () => { funds.replace(loaded); acceptingOwnerWork = true; state.value = 'unlocked'; + await funds.public.recoverPendingMints(ownerFence.capture()); idleWatch.start(); } catch (error) { stopOwnerChanges(); @@ -323,6 +325,7 @@ export const useWalletStore = defineStore('wallet', () => { const funds = createWalletFunds({ requireKey, + requireCashRoot: () => cashNodeFromHex(requireWalletMaterial().cashRootHex), ownerId: () => pubkey.value ?? undefined, setAuxiliaryError: (message) => { auxiliaryError.value = message; diff --git a/src/stores/walletFunds.atomic.test.ts b/src/stores/walletFunds.atomic.test.ts index 5f53fae..5544914 100644 --- a/src/stores/walletFunds.atomic.test.ts +++ b/src/stores/walletFunds.atomic.test.ts @@ -1,5 +1,5 @@ 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 { deriveBearerAesKey } from '@/lnurlcash/keys'; @@ -241,4 +241,23 @@ describe('wallet BIP-32 counter allocation', () => { expect(encrypt).not.toHaveBeenCalled(); 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); + }); }); diff --git a/src/stores/walletFunds.recovery.test.ts b/src/stores/walletFunds.recovery.test.ts index 97a83e8..8d8b894 100644 --- a/src/stores/walletFunds.recovery.test.ts +++ b/src/stores/walletFunds.recovery.test.ts @@ -1,8 +1,10 @@ import { createPinia, setActivePinia } from 'pinia'; -import { buildNoteUrl } from 'lnurlcash-kit'; +import { buildNoteUrl, cashNodeFromHex } from 'lnurlcash-kit'; import { createMockMint } from 'lnurlcash-conformance/mock-mint'; 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 type { NewBearer } from '@/lnurlcash/types'; import { useWalletStore } from './wallet'; @@ -329,9 +331,9 @@ describe('pending mint recovery', () => { it('ends recovery when the staged target is definitively spent', async () => { const instance = await mint(); - const secret = '1'.repeat(64); const wallet = useWalletStore(); await wallet.create(); + const secret = '1'.repeat(64); const [staged] = await wallet.addBearers( [stagedNote(instance, secret)], wallet.captureOwnerFence(), @@ -347,4 +349,81 @@ describe('pending mint recovery', () => { 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 }); + }); }); diff --git a/src/stores/walletFunds.ts b/src/stores/walletFunds.ts index c4608b1..15eb9a0 100644 --- a/src/stores/walletFunds.ts +++ b/src/stores/walletFunds.ts @@ -1,6 +1,6 @@ // allow: SIZE_OK — cohesive encrypted-bearer repository with one serialized mutation boundary. 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 { @@ -9,6 +9,7 @@ import { mergeBearers, persistBearer, readFundsRevision, + readPendingJournal, reserveCashIndices, } from '@/lnurlcash/storage'; import type { BearerChangeset } from '@/lnurlcash/storage'; @@ -55,6 +56,7 @@ export type AllocatedCashSecrets = { type WalletFundsOptions = { readonly requireKey: () => CryptoKey; + readonly requireCashRoot: () => CashNode; readonly ownerId: () => string | undefined; readonly setAuxiliaryError: (message: string) => void; }; @@ -409,6 +411,33 @@ export const createWalletFunds = (options: WalletFundsOptions) => { await removeNote(staged.id, ownerFence); 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(); break; 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(() => { 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 => + allocateCashSecrets(options.requireCashRoot(), server, count, ownerFence).then( + (allocated) => allocated.secrets, + ); + return { public: { bearers, @@ -554,6 +620,7 @@ export const createWalletFunds = (options: WalletFundsOptions) => { finalizeSpentStagedMintOutput, recoverPendingMints, allocateCashSecrets, + allocateOutputSecrets, markSpent, removeNote, mergeExternalBearers,