feat: native biometric unlock via biometric-gated wrap of the linking key

This commit is contained in:
2026-08-20 08:31:59 +02:00
parent f525b06966
commit e59b722b5e
3 changed files with 284 additions and 156 deletions
+36 -1
View File
@@ -50,6 +50,17 @@
@click="unlockViaPasskey" @click="unlockViaPasskey"
/> />
<q-btn
v-if="biometricAvailable"
outline
color="primary"
icon="fingerprint"
label="Unlock with biometrics"
class="full-width q-mt-sm"
:loading="biometricBusy"
@click="unlockViaBiometric"
/>
<div class="text-center q-mt-md"> <div class="text-center q-mt-md">
<q-btn <q-btn
flat flat
@@ -65,9 +76,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { biometricUnlockAvailable } from '@/capabilities/biometricUnlock';
import { hasPasskeySlots } from '@/lnurlcash/passkeys'; import { hasPasskeySlots } from '@/lnurlcash/passkeys';
import { useWalletStore } from '@/stores/wallet'; import { useWalletStore } from '@/stores/wallet';
@@ -86,6 +98,29 @@ const error = ref('');
const passkeyAvailable = hasPasskeySlots(); const passkeyAvailable = hasPasskeySlots();
const passkeyBusy = ref(false); const passkeyBusy = ref(false);
// native biometric unlock (capabilities/biometricUnlock.ts): only probed
// async because hardware availability is a plugin call; always false on web
const biometricAvailable = ref(false);
const biometricBusy = ref(false);
onMounted(async () => {
biometricAvailable.value = await biometricUnlockAvailable();
});
const unlockViaBiometric = async () => {
if (biometricBusy.value) return;
biometricBusy.value = true;
error.value = '';
try {
await wallet.unlockWithBiometric();
emit('unlocked');
} catch (err) {
error.value = err instanceof Error ? err.message : 'Biometric unlock failed.';
} finally {
biometricBusy.value = false;
}
};
const unlockViaPasskey = async () => { const unlockViaPasskey = async () => {
if (passkeyBusy.value) return; if (passkeyBusy.value) return;
passkeyBusy.value = true; passkeyBusy.value = true;
+88
View File
@@ -73,6 +73,55 @@
</template> </template>
</q-list> </q-list>
<!-- biometric unlock (native app only - Android WebView has no WebAuthn
platform authenticator for us, so this is the native biometric path) -->
<q-list v-if="biometricNative" class="sattle-card q-mb-md" bordered>
<q-item-label header class="text-primary text-weight-bold"> Biometric unlock </q-item-label>
<q-item v-if="wallet.state !== 'unlocked'">
<q-item-section class="text-grey-5">
Unlock your wallet first - enabling biometric unlock needs the wallet's key in memory.
</q-item-section>
</q-item>
<template v-else>
<q-item>
<q-item-section>
<q-item-label class="text-grey-3">
{{ biometricEnrolled ? 'Biometric unlock is on' : 'Biometric unlock is off' }}
</q-item-label>
<q-item-label caption class="text-grey-5" style="white-space: normal">
Unlock this wallet with your device's screen lock instead of the password. The wallet
key stays wrapped on this device; the biometric prompt gates reading it.
</q-item-label>
</q-item-section>
</q-item>
<div class="q-pa-md">
<q-btn
v-if="!biometricEnrolled"
unelevated
color="primary"
text-color="dark"
icon="fingerprint"
label="Enable biometric unlock"
class="full-width"
:loading="biometricBusy"
@click="doEnableBiometric"
/>
<q-btn
v-else
outline
no-caps
color="negative"
label="Disable biometric unlock"
class="full-width"
:loading="biometricBusy"
@click="doDisableBiometric"
/>
</div>
</template>
</q-list>
<!-- auto-lock --> <!-- auto-lock -->
<q-list class="sattle-card q-mb-md" bordered> <q-list class="sattle-card q-mb-md" bordered>
<q-item-label header class="text-primary text-weight-bold">Auto-lock</q-item-label> <q-item-label header class="text-primary text-weight-bold">Auto-lock</q-item-label>
@@ -154,6 +203,12 @@ import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
import {
disableBiometricUnlock,
enableBiometricUnlock,
isBiometricUnlockEnrolled,
} from '@/capabilities/biometricUnlock';
import { isNative } from '@/capabilities/platform';
import type { PasskeySlot } from '@/lnurlcash/passkeys'; import type { PasskeySlot } from '@/lnurlcash/passkeys';
import { import {
passkeySupported, passkeySupported,
@@ -185,6 +240,39 @@ onMounted(async () => {
slots.value = readPasskeySlots(); slots.value = readPasskeySlots();
}); });
// ---- biometric unlock (native only - see capabilities/biometricUnlock.ts) ----
const biometricNative = isNative();
const biometricEnrolled = ref(biometricNative && isBiometricUnlockEnrolled());
const biometricBusy = ref(false);
const doEnableBiometric = async () => {
biometricBusy.value = true;
banner.value = '';
try {
await enableBiometricUnlock(wallet.requireLinkingKey());
biometricEnrolled.value = true;
toast('positive', 'Biometric unlock enabled.');
} catch (err) {
banner.value = err instanceof Error ? err.message : 'Could not enable biometric unlock.';
} finally {
biometricBusy.value = false;
}
};
const doDisableBiometric = async () => {
biometricBusy.value = true;
banner.value = '';
try {
await disableBiometricUnlock();
biometricEnrolled.value = false;
toast('positive', 'Biometric unlock disabled.');
} catch (err) {
banner.value = err instanceof Error ? err.message : 'Could not disable biometric unlock.';
} finally {
biometricBusy.value = false;
}
};
// ---- register ---- // ---- register ----
const registering = ref(false); const registering = ref(false);
const registerName = ref(''); const registerName = ref('');
+160 -155
View File
@@ -1,6 +1,6 @@
import {computed, ref} from 'vue' import { computed, ref } from 'vue';
import {defineStore} from 'pinia' import { defineStore } from 'pinia';
import {serverOf} from 'lnurlcash-kit' import { serverOf } from 'lnurlcash-kit';
import { import {
deriveWalletLinkingKey, deriveWalletLinkingKey,
@@ -13,109 +13,108 @@ import {
clearSavedLinkingKey, clearSavedLinkingKey,
generateSeedPhrase, generateSeedPhrase,
isValidSeedPhrase, isValidSeedPhrase,
linkingPubKeyHex linkingPubKeyHex,
} from '@/lnurlcash/keys' } from '@/lnurlcash/keys';
import type {Bearer, NewBearer} from '@/lnurlcash/types' import type { Bearer, NewBearer } from '@/lnurlcash/types';
import { import {
loadBearers, loadBearers,
persistBearer, persistBearer,
deleteBearerRecord, deleteBearerRecord,
clearAllBearers, clearAllBearers,
newBearerId, newBearerId,
mergeBearers mergeBearers,
} from '@/lnurlcash/storage' } from '@/lnurlcash/storage';
import { import {
grandfatherTrustedMint, grandfatherTrustedMint,
lockTrustedMint, lockTrustedMint,
clearTrustedMints clearTrustedMints,
} from '@/lnurlcash/trustedMints' } from '@/lnurlcash/trustedMints';
import {clearSettings} from '@/lnurlcash/storage' import { clearSettings } from '@/lnurlcash/storage';
import {unlockWithPasskey as unlockWithPasskeyEngine} from '@/lnurlcash/passkeys' import { unlockWithPasskey as unlockWithPasskeyEngine } from '@/lnurlcash/passkeys';
import {msatToSats} from '@/lnurlcash/units' import { disableBiometricUnlock, unlockWithBiometrics } from '@/capabilities/biometricUnlock';
import {useActivityStore} from './activity' import { msatToSats } from '@/lnurlcash/units';
import { useActivityStore } from './activity';
// 'none': no wallet on this device yet -> setup // 'none': no wallet on this device yet -> setup
// 'locked': linking key present but password-encrypted -> unlock // 'locked': linking key present but password-encrypted -> unlock
// 'unlocked': linking key (and thus the bearer AES key) in memory // 'unlocked': linking key (and thus the bearer AES key) in memory
export type WalletState = 'none' | 'locked' | 'unlocked' export type WalletState = 'none' | 'locked' | 'unlocked';
// idle-timeout auto-lock: only meaningful for a password-encrypted key (see // idle-timeout auto-lock: only meaningful for a password-encrypted key (see
// lock(), which no-ops otherwise) - 5 minutes with no activity anywhere in // lock(), which no-ops otherwise) - 5 minutes with no activity anywhere in
// the tab locks the wallet. lockWarningSecondsLeft goes non-null 30s ahead // the tab locks the wallet. lockWarningSecondsLeft goes non-null 30s ahead
// of that so the UI can warn, and postponeLock() is the "stay unlocked" // of that so the UI can warn, and postponeLock() is the "stay unlocked"
// hook it offers. // hook it offers.
const AUTO_LOCK_MS = 5 * 60 * 1000 const AUTO_LOCK_MS = 5 * 60 * 1000;
const LOCK_WARNING_MS = 30 * 1000 const LOCK_WARNING_MS = 30 * 1000;
// a plaintext-stored key also starts 'locked' - init() unlocks it // a plaintext-stored key also starts 'locked' - init() unlocks it
// immediately without a password, keeping a single code path for deriving // immediately without a password, keeping a single code path for deriving
// the AES key and loading bearers // the AES key and loading bearers
const initialState = (): WalletState => (savedKeyExists() ? 'locked' : 'none') const initialState = (): WalletState => (savedKeyExists() ? 'locked' : 'none');
export const useWalletStore = defineStore('wallet', () => { export const useWalletStore = defineStore('wallet', () => {
const state = ref<WalletState>(initialState()) const state = ref<WalletState>(initialState());
const bearers = ref<Bearer[]>([]) const bearers = ref<Bearer[]>([]);
const pubkey = ref<string | null>(null) const pubkey = ref<string | null>(null);
let aesKey: CryptoKey | null = null let aesKey: CryptoKey | null = null;
// the linking key itself, only while unlocked - needed by backup/passkey // the linking key itself, only while unlocked - needed by backup/passkey
// operations (nostrBackup derives the backup key from it, passkey // operations (nostrBackup derives the backup key from it, passkey
// registration wraps it). Never exposed reactively; cleared on lock/forget // registration wraps it). Never exposed reactively; cleared on lock/forget
let currentLinkingKey: Uint8Array | null = null let currentLinkingKey: Uint8Array | null = null;
// ---- idle auto-lock bookkeeping ---- // ---- idle auto-lock bookkeeping ----
let lastActivity = Date.now() let lastActivity = Date.now();
let idleTimer: ReturnType<typeof setInterval> | null = null let idleTimer: ReturnType<typeof setInterval> | null = null;
const lockWarningSecondsLeft = ref<number | null>(null) const lockWarningSecondsLeft = ref<number | null>(null);
const encrypted = computed(() => savedKeyIsEncrypted()) const encrypted = computed(() => savedKeyIsEncrypted());
// ---- balances: protocol layer is msat; sats are a display helper ---- // ---- balances: protocol layer is msat; sats are a display helper ----
const unspentBearers = computed(() => bearers.value.filter(b => !b.spent)) const unspentBearers = computed(() => bearers.value.filter((b) => !b.spent));
const balanceMsat = computed(() => const balanceMsat = computed(() => unspentBearers.value.reduce((sum, b) => sum + b.amount, 0));
unspentBearers.value.reduce((sum, b) => sum + b.amount, 0) const balanceSats = computed(() => msatToSats(balanceMsat.value));
)
const balanceSats = computed(() => msatToSats(balanceMsat.value))
const balanceByMintMsat = computed(() => { const balanceByMintMsat = computed(() => {
const byMint = new Map<string, number>() const byMint = new Map<string, number>();
for (const b of unspentBearers.value) { for (const b of unspentBearers.value) {
const server = serverOf(b.url) const server = serverOf(b.url);
byMint.set(server, (byMint.get(server) ?? 0) + b.amount) byMint.set(server, (byMint.get(server) ?? 0) + b.amount);
} }
return byMint return byMint;
}) });
const balanceByMintSats = computed(() => { const balanceByMintSats = computed(() => {
const byMint = new Map<string, number>() const byMint = new Map<string, number>();
for (const [server, msat] of balanceByMintMsat.value) { for (const [server, msat] of balanceByMintMsat.value) {
byMint.set(server, msatToSats(msat)) byMint.set(server, msatToSats(msat));
} }
return byMint return byMint;
}) });
const stopIdleWatch = () => { const stopIdleWatch = () => {
if (idleTimer) clearInterval(idleTimer) if (idleTimer) clearInterval(idleTimer);
idleTimer = null idleTimer = null;
lockWarningSecondsLeft.value = null lockWarningSecondsLeft.value = null;
} };
const lock = () => { const lock = () => {
// only meaningful for a password-encrypted key - a plaintext one would // only meaningful for a password-encrypted key - a plaintext one would
// just auto-unlock again, so the UI only offers Lock when encrypted // just auto-unlock again, so the UI only offers Lock when encrypted
if (!savedKeyIsEncrypted()) return if (!savedKeyIsEncrypted()) return;
aesKey = null aesKey = null;
currentLinkingKey = null currentLinkingKey = null;
pubkey.value = null pubkey.value = null;
bearers.value = [] bearers.value = [];
useActivityStore().unload() useActivityStore().unload();
stopIdleWatch() stopIdleWatch();
state.value = 'locked' state.value = 'locked';
} };
// any real interaction restarts the 5-minute clock and clears the // any real interaction restarts the 5-minute clock and clears the
// warning - the UI's "Stay unlocked" button calls this // warning - the UI's "Stay unlocked" button calls this
const postponeLock = () => { const postponeLock = () => {
lastActivity = Date.now() lastActivity = Date.now();
lockWarningSecondsLeft.value = null lockWarningSecondsLeft.value = null;
} };
// ticks once a second while unlocked and encrypted (the only state // ticks once a second while unlocked and encrypted (the only state
// auto-lock applies to), comparing wall-clock time against lastActivity // auto-lock applies to), comparing wall-clock time against lastActivity
@@ -123,40 +122,40 @@ export const useWalletStore = defineStore('wallet', () => {
// throttles timers but Date.now() still reflects real elapsed time // throttles timers but Date.now() still reflects real elapsed time
// whenever this next gets to run // whenever this next gets to run
const startIdleWatch = () => { const startIdleWatch = () => {
stopIdleWatch() stopIdleWatch();
if (typeof window === 'undefined' || !savedKeyIsEncrypted()) return if (typeof window === 'undefined' || !savedKeyIsEncrypted()) return;
lastActivity = Date.now() lastActivity = Date.now();
const registerActivity = () => { const registerActivity = () => {
// once the warning is up, passive activity is deliberately ignored - // once the warning is up, passive activity is deliberately ignored -
// only postponeLock() dismisses it, so the "stay unlocked" button // only postponeLock() dismisses it, so the "stay unlocked" button
// can't vanish out from under the pointer before the click lands // can't vanish out from under the pointer before the click lands
if (state.value === 'unlocked' && lockWarningSecondsLeft.value === null) { if (state.value === 'unlocked' && lockWarningSecondsLeft.value === null) {
lastActivity = Date.now() lastActivity = Date.now();
}
} }
};
for (const event of ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll']) { for (const event of ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll']) {
window.addEventListener(event, registerActivity, {passive: true}) window.addEventListener(event, registerActivity, { passive: true });
} }
idleTimer = setInterval(() => { idleTimer = setInterval(() => {
if (state.value !== 'unlocked') return if (state.value !== 'unlocked') return;
const elapsed = Date.now() - lastActivity const elapsed = Date.now() - lastActivity;
if (elapsed >= AUTO_LOCK_MS) { if (elapsed >= AUTO_LOCK_MS) {
lock() lock();
} else if (elapsed >= AUTO_LOCK_MS - LOCK_WARNING_MS) { } else if (elapsed >= AUTO_LOCK_MS - LOCK_WARNING_MS) {
lockWarningSecondsLeft.value = Math.ceil((AUTO_LOCK_MS - elapsed) / 1000) lockWarningSecondsLeft.value = Math.ceil((AUTO_LOCK_MS - elapsed) / 1000);
}
}, 1000)
} }
}, 1000);
};
const activate = async (linkingKey: Uint8Array) => { const activate = async (linkingKey: Uint8Array) => {
const key = await deriveBearerAesKey(linkingKey) const key = await deriveBearerAesKey(linkingKey);
aesKey = key aesKey = key;
currentLinkingKey = linkingKey currentLinkingKey = linkingKey;
pubkey.value = linkingPubKeyHex(linkingKey) pubkey.value = linkingPubKeyHex(linkingKey);
const loaded = await loadBearers(key) const loaded = await loadBearers(key);
bearers.value = loaded bearers.value = loaded;
const activity = useActivityStore() const activity = useActivityStore();
await activity.loadFor(key) await activity.loadFor(key);
// grandfather in every mint already backing a held bearer as trusted - // grandfather in every mint already backing a held bearer as trusted -
// holding funds there already implied trusting it. Storage-sourced // holding funds there already implied trusting it. Storage-sourced
// claims only, though: grandfathering never locks and marks new pins // claims only, though: grandfathering never locks and marks new pins
@@ -164,55 +163,58 @@ export const useWalletStore = defineStore('wallet', () => {
// bearer operations // bearer operations
for (const bearer of loaded) { for (const bearer of loaded) {
if (bearer.mintPubkey) { if (bearer.mintPubkey) {
grandfatherTrustedMint(serverOf(bearer.url), bearer.mintPubkey) grandfatherTrustedMint(serverOf(bearer.url), bearer.mintPubkey);
} }
} }
state.value = 'unlocked' state.value = 'unlocked';
startIdleWatch() startIdleWatch();
} };
// generates a fresh seed phrase, derives and saves the linking key, and // generates a fresh seed phrase, derives and saves the linking key, and
// unlocks. The phrase is returned exactly once - it is never stored, so // unlocks. The phrase is returned exactly once - it is never stored, so
// the caller MUST show it to the holder before letting them move on. // the caller MUST show it to the holder before letting them move on.
const create = async (password?: string): Promise<string> => { const create = async (password?: string): Promise<string> => {
const phrase = generateSeedPhrase() const phrase = generateSeedPhrase();
await restoreFromSeed(phrase, password) await restoreFromSeed(phrase, password);
return phrase return phrase;
} };
const restoreFromSeed = async ( const restoreFromSeed = async (seedPhrase: string, password?: string): Promise<void> => {
seedPhrase: string,
password?: string
): Promise<void> => {
if (!isValidSeedPhrase(seedPhrase)) { if (!isValidSeedPhrase(seedPhrase)) {
throw new Error('Not a valid seed phrase.') throw new Error('Not a valid seed phrase.');
}
const linkingKey = deriveWalletLinkingKey(seedPhrase)
await saveLinkingKey(linkingKey, password)
await activate(linkingKey)
} }
const linkingKey = deriveWalletLinkingKey(seedPhrase);
await saveLinkingKey(linkingKey, password);
await activate(linkingKey);
};
const unlock = async (password?: string): Promise<void> => { const unlock = async (password?: string): Promise<void> => {
const linkingKey = savedKeyIsEncrypted() const linkingKey = savedKeyIsEncrypted()
? await decryptSavedLinkingKey(password || '') ? await decryptSavedLinkingKey(password || '')
: getPlainLinkingKey() : getPlainLinkingKey();
if (!linkingKey) throw new Error('No wallet on this device.') if (!linkingKey) throw new Error('No wallet on this device.');
await activate(linkingKey) await activate(linkingKey);
} };
// passkey unlock (passkeys.ts): the ceremony unwraps the SAME linking key // passkey unlock (passkeys.ts): the ceremony unwraps the SAME linking key
// the password path protects, so activation is identical either way // the password path protects, so activation is identical either way
const unlockWithPasskey = async (): Promise<void> => { const unlockWithPasskey = async (): Promise<void> => {
await activate(await unlockWithPasskeyEngine()) await activate(await unlockWithPasskeyEngine());
} };
// native biometric unlock (capabilities/biometricUnlock.ts): a third wrap
// of the same linking key, behind the device credential prompt
const unlockWithBiometric = async (): Promise<void> => {
await activate(await unlockWithBiometrics());
};
// app-start entry point (boot/wallet.ts): a plaintext-stored key unlocks // app-start entry point (boot/wallet.ts): a plaintext-stored key unlocks
// without a password; an encrypted one waits on the unlock screen // without a password; an encrypted one waits on the unlock screen
const init = async (): Promise<void> => { const init = async (): Promise<void> => {
if (state.value === 'locked' && !savedKeyIsEncrypted()) { if (state.value === 'locked' && !savedKeyIsEncrypted()) {
await unlock() await unlock();
}
} }
};
// wipes this wallet from the device entirely - the linking key, every // wipes this wallet from the device entirely - the linking key, every
// bearer record, the activity log, and the non-secret registries that // bearer record, the activity log, and the non-secret registries that
@@ -221,31 +223,36 @@ export const useWalletStore = defineStore('wallet', () => {
// gone); only a backup downloaded before this runs can bring the notes // gone); only a backup downloaded before this runs can bring the notes
// back - the UI should prompt for one // back - the UI should prompt for one
const forgetWallet = () => { const forgetWallet = () => {
clearSavedLinkingKey() clearSavedLinkingKey();
clearAllBearers() // the biometric wrap belongs to this wallet's linking key - drop it too.
clearTrustedMints() // The record removal is synchronous inside; the secure-storage delete
clearSettings() // trails behind, and an orphaned secret there is unusable without the
useActivityStore().unloadAndClear() // record, so a failed delete is safe to swallow
aesKey = null void disableBiometricUnlock().catch(() => {});
currentLinkingKey = null clearAllBearers();
pubkey.value = null clearTrustedMints();
bearers.value = [] clearSettings();
stopIdleWatch() useActivityStore().unloadAndClear();
state.value = 'none' aesKey = null;
} currentLinkingKey = null;
pubkey.value = null;
bearers.value = [];
stopIdleWatch();
state.value = 'none';
};
const requireKey = (): CryptoKey => { const requireKey = (): CryptoKey => {
if (!aesKey) throw new Error('Wallet is locked.') if (!aesKey) throw new Error('Wallet is locked.');
return aesKey return aesKey;
} };
// narrow accessor for the operations that need the key material itself // narrow accessor for the operations that need the key material itself
// (nostr backup key derivation, passkey registration) - never reactive, // (nostr backup key derivation, passkey registration) - never reactive,
// throws when locked, so callers can't accidentally hold a stale key // throws when locked, so callers can't accidentally hold a stale key
const requireLinkingKey = (): Uint8Array => { const requireLinkingKey = (): Uint8Array => {
if (!currentLinkingKey) throw new Error('Wallet is locked.') if (!currentLinkingKey) throw new Error('Wallet is locked.');
return currentLinkingKey return currentLinkingKey;
} };
// the one entry point for new notes (minted, received, carved outputs): // the one entry point for new notes (minted, received, carved outputs):
// persists first, then updates state. Holding a bearer from a mint // persists first, then updates state. Holding a bearer from a mint
@@ -254,64 +261,61 @@ export const useWalletStore = defineStore('wallet', () => {
// 'rekey-pending' and is staged on the mints store for review, never // 'rekey-pending' and is staged on the mints store for review, never
// auto-applied. // auto-applied.
const addBearers = async (notes: NewBearer[]): Promise<Bearer[]> => { const addBearers = async (notes: NewBearer[]): Promise<Bearer[]> => {
const now = Date.now() const now = Date.now();
const added: Bearer[] = [] const added: Bearer[] = [];
for (const note of notes) { for (const note of notes) {
const bearer: Bearer = { const bearer: Bearer = {
id: newBearerId(), id: newBearerId(),
...note, ...note,
createdAt: now, createdAt: now,
updatedAt: now updatedAt: now,
};
await persistBearer(requireKey(), bearer);
added.push(bearer);
} }
await persistBearer(requireKey(), bearer) bearers.value = [...added, ...bearers.value];
added.push(bearer)
}
bearers.value = [...added, ...bearers.value]
for (const bearer of added) { for (const bearer of added) {
if (bearer.mintPubkey) { if (bearer.mintPubkey) {
lockTrustedMint(serverOf(bearer.url), bearer.mintPubkey) lockTrustedMint(serverOf(bearer.url), bearer.mintPubkey);
} }
} }
return added return added;
} };
const updateBearer = async ( const updateBearer = async (id: string, changes: Partial<Omit<Bearer, 'id'>>): Promise<void> => {
id: string, const current = bearers.value.find((b) => b.id === id);
changes: Partial<Omit<Bearer, 'id'>> if (!current) return;
): Promise<void> => { const updated: Bearer = { ...current, ...changes, updatedAt: Date.now() };
const current = bearers.value.find(b => b.id === id) await persistBearer(requireKey(), updated);
if (!current) return bearers.value = bearers.value.map((b) => (b.id === id ? updated : b));
const updated: Bearer = {...current, ...changes, updatedAt: Date.now()}
await persistBearer(requireKey(), updated)
bearers.value = bearers.value.map(b => (b.id === id ? updated : b))
if (updated.mintPubkey) { if (updated.mintPubkey) {
lockTrustedMint(serverOf(updated.url), updated.mintPubkey) lockTrustedMint(serverOf(updated.url), updated.mintPubkey);
}
} }
};
const markSpent = async (id: string, spent = true): Promise<void> => { const markSpent = async (id: string, spent = true): Promise<void> => {
await updateBearer(id, {spent}) await updateBearer(id, { spent });
} };
const removeNote = async (id: string): Promise<void> => { const removeNote = async (id: string): Promise<void> => {
bearers.value = bearers.value.filter(b => b.id !== id) bearers.value = bearers.value.filter((b) => b.id !== id);
await deleteBearerRecord(id) await deleteBearerRecord(id);
} };
// merges externally-produced bearers (a decrypted backup restore, later a // merges externally-produced bearers (a decrypted backup restore, later a
// nostr restore) into the live list: union by note identity, spent-wins - // nostr restore) into the live list: union by note identity, spent-wins -
// see storage.ts's mergeBearers. Persists every survivor. // see storage.ts's mergeBearers. Persists every survivor.
const mergeExternalBearers = async (incoming: Bearer[]): Promise<void> => { const mergeExternalBearers = async (incoming: Bearer[]): Promise<void> => {
const merged = mergeBearers(bearers.value, incoming) const merged = mergeBearers(bearers.value, incoming);
for (const bearer of merged) { for (const bearer of merged) {
await persistBearer(requireKey(), bearer) await persistBearer(requireKey(), bearer);
}
bearers.value = merged
} }
bearers.value = merged;
};
const reloadBearers = async (): Promise<void> => { const reloadBearers = async (): Promise<void> => {
bearers.value = await loadBearers(requireKey()) bearers.value = await loadBearers(requireKey());
} };
return { return {
state, state,
@@ -328,6 +332,7 @@ export const useWalletStore = defineStore('wallet', () => {
restoreFromSeed, restoreFromSeed,
unlock, unlock,
unlockWithPasskey, unlockWithPasskey,
unlockWithBiometric,
lock, lock,
init, init,
forgetWallet, forgetWallet,
@@ -338,6 +343,6 @@ export const useWalletStore = defineStore('wallet', () => {
markSpent, markSpent,
removeNote, removeNote,
mergeExternalBearers, mergeExternalBearers,
reloadBearers reloadBearers,
} };
}) });