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.
|
// 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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user