mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
feat(wallet): allocate operation secrets from the cash root
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<readonly string[]> =>
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user