test(funds): fence harness on v2 owner install

This commit is contained in:
2026-09-05 09:29:05 +02:00
parent e94549a16e
commit ff9e8a073e
3 changed files with 47 additions and 25 deletions
+10 -3
View File
@@ -9,7 +9,7 @@ import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
import {v2 as nip44v2} from 'nostr-tools/nip44' import {v2 as nip44v2} from 'nostr-tools/nip44'
import {buildNoteUrl} from 'lnurlcash-kit' import {buildNoteUrl} from 'lnurlcash-kit'
import {deriveBearerAesKey, linkingPubKeyHex, saveLinkingKey} from './keys' import {deriveBearerAesKey, deriveWalletMaterial, linkingPubKeyHex, saveWalletMaterial} from './keys'
import { import {
BACKUP_EVENT_KIND, BACKUP_EVENT_KIND,
backupPubkey, backupPubkey,
@@ -40,6 +40,13 @@ import {requiredValue, stubLocalStorage} from './test-utils'
const LINKING_KEY = new Uint8Array(32).fill(7) const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_KEY = new Uint8Array(32).fill(9) const OTHER_KEY = new Uint8Array(32).fill(9)
const OWNER_ID = linkingPubKeyHex(LINKING_KEY) const OWNER_ID = linkingPubKeyHex(LINKING_KEY)
// full v2 material for the fixed harness linking key (real BIP-32 cash root)
const MATERIAL = {
...deriveWalletMaterial(
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
),
linkingKeyHex: bytesToHex(LINKING_KEY),
}
const K1_A = 'aa'.repeat(32) const K1_A = 'aa'.repeat(32)
const K1_B = 'bb'.repeat(32) const K1_B = 'bb'.repeat(32)
@@ -103,7 +110,7 @@ describe('restoreFromNostr', () => {
const {transport} = createRecordingTransport() const {transport} = createRecordingTransport()
// device A: one note, one trusted mint, one setting - all published // device A: one note, one trusted mint, one setting - all published
await saveLinkingKey(LINKING_KEY) await saveWalletMaterial(MATERIAL)
await persistBearer(aesKey, bearerFixture({id: 'note-a'})) await persistBearer(aesKey, bearerFixture({id: 'note-a'}))
await addTrustedMint('mint.example', MINT_PUBKEY, {ownerId: OWNER_ID}) await addTrustedMint('mint.example', MINT_PUBKEY, {ownerId: OWNER_ID})
persistSettings({defaultMint: 'mint.example'}) persistSettings({defaultMint: 'mint.example'})
@@ -120,7 +127,7 @@ describe('restoreFromNostr', () => {
// device B: the same seed on empty storage // device B: the same seed on empty storage
stubLocalStorage() stubLocalStorage()
await saveLinkingKey(LINKING_KEY) await saveWalletMaterial(MATERIAL)
const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport}) const result = await restoreFromNostr(LINKING_KEY, RELAYS, {transport})
expect(result.found).toEqual(['notes', 'mints', 'settings']) expect(result.found).toEqual(['notes', 'mints', 'settings'])
+12 -2
View File
@@ -6,7 +6,9 @@
import {beforeEach, describe, expect, it, vi} from 'vitest' import {beforeEach, describe, expect, it, vi} from 'vitest'
import {linkingPubKeyHex, saveLinkingKey} from './keys' import {bytesToHex} from '@noble/hashes/utils.js'
import {deriveWalletMaterial, linkingPubKeyHex, saveWalletMaterial} from './keys'
import {applyBackup, buildBackup, parseBackupFile} from './storage' import {applyBackup, buildBackup, parseBackupFile} from './storage'
import {readFundsDocument, writeFundsDocument} from './storage/bearers' import {readFundsDocument, writeFundsDocument} from './storage/bearers'
import {addTrustedMint, readTrustedMints} from './trustedMints' import {addTrustedMint, readTrustedMints} from './trustedMints'
@@ -16,6 +18,14 @@ const LINKING_KEY = new Uint8Array(32).fill(7)
const OWNER_ID = linkingPubKeyHex(LINKING_KEY) const OWNER_ID = linkingPubKeyHex(LINKING_KEY)
const KEY_A = '02' + 'aa'.repeat(32) const KEY_A = '02' + 'aa'.repeat(32)
const KEY_B = '03' + 'bb'.repeat(32) const KEY_B = '03' + 'bb'.repeat(32)
// full v2 material for this harness's fixed linking key (real BIP-32 cash
// root, same construction as the lifecycle test harness)
const MATERIAL = {
...deriveWalletMaterial(
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
),
linkingKeyHex: bytesToHex(LINKING_KEY),
}
type LockRequest = { type LockRequest = {
readonly callback: () => unknown readonly callback: () => unknown
@@ -59,7 +69,7 @@ const backup = (server: string, mintPubkey: string) => ({
], ],
}) })
const installProvenOwner = (): Promise<void> => saveLinkingKey(LINKING_KEY) const installProvenOwner = (): Promise<void> => saveWalletMaterial(MATERIAL)
beforeEach(() => { beforeEach(() => {
vi.unstubAllGlobals() vi.unstubAllGlobals()
+25 -20
View File
@@ -2,7 +2,11 @@ import { createPinia, setActivePinia } from 'pinia';
import { buildNoteUrl } from 'lnurlcash-kit'; import { buildNoteUrl } from 'lnurlcash-kit';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { deriveBearerAesKey, linkingPubKeyHex } from '@/lnurlcash/keys'; import {
deriveBearerAesKey,
deriveWalletMaterial,
saveWalletMaterial,
} from '@/lnurlcash/keys';
import { loadBearers, readEncryptedBearers, readFundsDocument } from '@/lnurlcash/storage'; import { loadBearers, readEncryptedBearers, readFundsDocument } from '@/lnurlcash/storage';
import { FUNDS_STORAGE_KEY } from '@/lnurlcash/storage/bearers'; import { FUNDS_STORAGE_KEY } from '@/lnurlcash/storage/bearers';
import { WalletOwnerMismatchError } from '@/lnurlcash/storage/currentOwner'; import { WalletOwnerMismatchError } from '@/lnurlcash/storage/currentOwner';
@@ -11,7 +15,14 @@ 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';
const OTHER_OWNER_ID = linkingPubKeyHex(new Uint8Array(32).fill(9)); // a full v2 material for the successor owner: real BIP-32 cash root, fixed
// other linking key (same construction as the lifecycle test harness)
const OTHER_MATERIAL = {
...deriveWalletMaterial(
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
),
linkingKeyHex: '09'.repeat(32),
};
// fund commits REQUIRE Web Locks now; tests that don't care about lock // fund commits REQUIRE Web Locks now; tests that don't care about lock
// timing install a present-but-non-serializing fake // timing install a present-but-non-serializing fake
@@ -28,19 +39,13 @@ const note = (secret: string): NewBearer => ({
verified: true, verified: true,
}); });
// a second tab installing its own wallet: the saved-key record flips to the // a second tab installing its own wallet: the successor lifecycle writes its
// successor owner. Deliberately NO storage event is dispatched - the fence // v2 wallet-material record and deletes the legacy linking-key record, so the
// must not depend on the wakeup arriving first. // saved-key owner flips to the successor. Deliberately NO storage event is
const replacePersistedOwner = (): void => { // dispatched - the fence must not depend on the wakeup arriving first.
localStorage.setItem( const replacePersistedOwner = async (): Promise<void> => {
'sattle_linking_key', localStorage.removeItem('sattle_linking_key');
JSON.stringify({ await saveWalletMaterial(OTHER_MATERIAL);
enc: false,
value: '09'.repeat(32),
ownerId: OTHER_OWNER_ID,
version: 1,
}),
);
}; };
type LockRequest = { type LockRequest = {
@@ -90,7 +95,7 @@ describe('stale-owner fencing of fund commits', () => {
// When another tab replaced the wallet and this (still unlocked) tab // When another tab replaced the wallet and this (still unlocked) tab
// tries to commit a fund change // tries to commit a fund change
replacePersistedOwner(); await replacePersistedOwner();
await expect( await expect(
wallet.applyChangeset({ add: [note('bb')], markSpent: [existing.id] }, ownerFence), wallet.applyChangeset({ add: [note('bb')], markSpent: [existing.id] }, ownerFence),
).rejects.toBeInstanceOf(WalletOwnerMismatchError); ).rejects.toBeInstanceOf(WalletOwnerMismatchError);
@@ -109,7 +114,7 @@ describe('stale-owner fencing of fund commits', () => {
const ownerFence = wallet.captureOwnerFence(); const ownerFence = wallet.captureOwnerFence();
const [existing] = await wallet.addBearers([note('aa')], ownerFence); const [existing] = await wallet.addBearers([note('aa')], ownerFence);
if (!existing) throw new Error('Expected the initial bearer.'); if (!existing) throw new Error('Expected the initial bearer.');
replacePersistedOwner(); await replacePersistedOwner();
// When the stale tab marks the note spent // When the stale tab marks the note spent
await expect(wallet.markSpent(existing.id, ownerFence)).rejects.toBeInstanceOf( await expect(wallet.markSpent(existing.id, ownerFence)).rejects.toBeInstanceOf(
@@ -140,7 +145,7 @@ describe('stale-owner fencing of fund commits', () => {
ownerFence, ownerFence,
); );
await vi.waitFor(() => expect(locks.requests.length).toBeGreaterThan(0)); await vi.waitFor(() => expect(locks.requests.length).toBeGreaterThan(0));
replacePersistedOwner(); await replacePersistedOwner();
await locks.releaseNext(); await locks.releaseNext();
// Then the commit fails closed instead of writing stale-owner ciphertext // Then the commit fails closed instead of writing stale-owner ciphertext
@@ -185,7 +190,7 @@ describe('stale-owner fencing of fund commits', () => {
const updating = wallet.markSpent(existing.id, ownerFence); const updating = wallet.markSpent(existing.id, ownerFence);
await vi.waitFor(() => expect(locks.requests.length).toBeGreaterThan(0)); await vi.waitFor(() => expect(locks.requests.length).toBeGreaterThan(0));
replacePersistedOwner(); await replacePersistedOwner();
await locks.releaseNext(); await locks.releaseNext();
await expect(updating).rejects.toBeInstanceOf(WalletOwnerMismatchError); await expect(updating).rejects.toBeInstanceOf(WalletOwnerMismatchError);
@@ -206,7 +211,7 @@ describe('stale-owner fencing of fund commits', () => {
const removing = wallet.removeNote(existing.id, ownerFence); const removing = wallet.removeNote(existing.id, ownerFence);
await vi.waitFor(() => expect(locks.requests.length).toBeGreaterThan(0)); await vi.waitFor(() => expect(locks.requests.length).toBeGreaterThan(0));
replacePersistedOwner(); await replacePersistedOwner();
await locks.releaseNext(); await locks.releaseNext();
await expect(removing).rejects.toBeInstanceOf(WalletOwnerMismatchError); await expect(removing).rejects.toBeInstanceOf(WalletOwnerMismatchError);