mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
feat: backup and security settings pages, nostr restore onboarding, passkey unlock entry
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { defineBoot } from '#q-app';
|
||||
import { useWalletStore } from '@/stores/wallet';
|
||||
import { useNostrBackupStore } from '@/stores/nostrBackup';
|
||||
|
||||
// Wallet lifecycle bootstrap: reflects whatever is on this device into the
|
||||
// wallet store at app start - a plaintext-stored key unlocks straight away,
|
||||
@@ -8,4 +9,7 @@ import { useWalletStore } from '@/stores/wallet';
|
||||
export default defineBoot(async () => {
|
||||
const wallet = useWalletStore();
|
||||
await wallet.init();
|
||||
// instantiating the store arms its watchers: while the wallet is unlocked
|
||||
// and nostr backup is enabled, store changes schedule debounced publishes
|
||||
useNostrBackupStore();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="modelValue.length" class="row q-gutter-xs q-mb-sm">
|
||||
<q-chip
|
||||
v-for="relay in modelValue"
|
||||
:key="relay"
|
||||
removable
|
||||
dense
|
||||
color="secondary"
|
||||
text-color="primary"
|
||||
:aria-label="`Remove ${relay}`"
|
||||
@remove="remove(relay)"
|
||||
>
|
||||
{{ relay }}
|
||||
</q-chip>
|
||||
</div>
|
||||
<div v-else class="text-caption text-warning q-mb-sm">
|
||||
No relays configured - a backup has nowhere to go.
|
||||
</div>
|
||||
<q-input
|
||||
v-model="draft"
|
||||
dark
|
||||
outlined
|
||||
dense
|
||||
color="primary"
|
||||
label="Add a relay"
|
||||
placeholder="wss://relay.example.com"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
:error="error !== ''"
|
||||
:error-message="error"
|
||||
class="q-mb-sm"
|
||||
@keyup.enter="add"
|
||||
/>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
no-caps
|
||||
color="primary"
|
||||
label="Add relay"
|
||||
:disable="draft.trim() === ''"
|
||||
@click="add"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { normalizeRelay } from '@/stores/nostrBackup';
|
||||
|
||||
const props = defineProps<{ modelValue: string[] }>();
|
||||
const emit = defineEmits<{ 'update:modelValue': [relays: string[]] }>();
|
||||
|
||||
const draft = ref('');
|
||||
const error = ref('');
|
||||
|
||||
const add = () => {
|
||||
error.value = '';
|
||||
try {
|
||||
const relay = normalizeRelay(draft.value);
|
||||
if (!props.modelValue.includes(relay)) {
|
||||
emit('update:modelValue', [...props.modelValue, relay]);
|
||||
}
|
||||
draft.value = '';
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'That is not a relay address.';
|
||||
}
|
||||
};
|
||||
|
||||
const remove = (relay: string) => {
|
||||
emit(
|
||||
'update:modelValue',
|
||||
props.modelValue.filter((r) => r !== relay),
|
||||
);
|
||||
};
|
||||
</script>
|
||||
@@ -39,6 +39,17 @@
|
||||
@click="unlock"
|
||||
/>
|
||||
|
||||
<q-btn
|
||||
v-if="passkeyAvailable"
|
||||
outline
|
||||
color="primary"
|
||||
icon="fingerprint"
|
||||
label="Unlock with passkey"
|
||||
class="full-width q-mt-sm"
|
||||
:loading="passkeyBusy"
|
||||
@click="unlockViaPasskey"
|
||||
/>
|
||||
|
||||
<div class="text-center q-mt-md">
|
||||
<q-btn
|
||||
flat
|
||||
@@ -57,6 +68,7 @@
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { hasPasskeySlots } from '@/lnurlcash/passkeys';
|
||||
import { useWalletStore } from '@/stores/wallet';
|
||||
|
||||
const emit = defineEmits<{ unlocked: [] }>();
|
||||
@@ -69,6 +81,25 @@ const showPassword = ref(false);
|
||||
const busy = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
// slots live in plain localStorage - a sync read at setup is enough; they
|
||||
// can only change from the security page while unlocked
|
||||
const passkeyAvailable = hasPasskeySlots();
|
||||
const passkeyBusy = ref(false);
|
||||
|
||||
const unlockViaPasskey = async () => {
|
||||
if (passkeyBusy.value) return;
|
||||
passkeyBusy.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await wallet.unlockWithPasskey();
|
||||
emit('unlocked');
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Passkey unlock failed.';
|
||||
} finally {
|
||||
passkeyBusy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const unlock = async () => {
|
||||
if (busy.value || password.value === '') return;
|
||||
busy.value = true;
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
export type WalletSettings = {
|
||||
defaultMint?: string
|
||||
// nostr backup (see nostrBackup.ts): off unless the holder turns it on;
|
||||
// relays are only persisted once edited - absent means the UI's defaults
|
||||
nostrBackupEnabled?: boolean
|
||||
nostrBackupRelays?: string[]
|
||||
}
|
||||
|
||||
const SETTINGS_STORAGE_KEY = 'sattle_settings'
|
||||
@@ -17,7 +21,14 @@ export const loadSettings = (): WalletSettings => {
|
||||
const s = parsed as Record<string, unknown>
|
||||
return {
|
||||
defaultMint:
|
||||
typeof s.defaultMint === 'string' ? s.defaultMint : undefined
|
||||
typeof s.defaultMint === 'string' ? s.defaultMint : undefined,
|
||||
nostrBackupEnabled:
|
||||
typeof s.nostrBackupEnabled === 'boolean'
|
||||
? s.nostrBackupEnabled
|
||||
: undefined,
|
||||
nostrBackupRelays: Array.isArray(s.nostrBackupRelays)
|
||||
? s.nostrBackupRelays.filter((r): r is string => typeof r === 'string')
|
||||
: undefined
|
||||
}
|
||||
} catch {
|
||||
return {}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
<template>
|
||||
<q-page class="q-pa-md">
|
||||
<div class="row items-center q-mb-md">
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
round
|
||||
color="primary"
|
||||
icon="arrow_back"
|
||||
aria-label="Back"
|
||||
@click="router.push('/settings')"
|
||||
/>
|
||||
<div class="text-h5 text-weight-bold text-primary q-ml-sm">Backup</div>
|
||||
</div>
|
||||
|
||||
<q-card v-if="wallet.state !== 'unlocked'" class="sattle-card q-pa-lg">
|
||||
<div class="text-body1 text-grey-4">
|
||||
Unlock your wallet first - backup operations need the wallet's key in memory.
|
||||
</div>
|
||||
</q-card>
|
||||
|
||||
<template v-else>
|
||||
<!-- recovery phrase: sattle never stores it, so there is nothing to
|
||||
reveal - the honest answer plus the paths that DO work -->
|
||||
<q-list class="sattle-card q-mb-md" bordered>
|
||||
<q-item-label header class="text-primary text-weight-bold"> Recovery phrase </q-item-label>
|
||||
<div class="q-pa-md q-pt-sm text-body2 text-grey-4">
|
||||
Your recovery phrase was shown exactly once when this wallet was created and is never
|
||||
stored anywhere - not encrypted, not on this device. It cannot be shown again. The backup
|
||||
file and nostr backup below are the recovery paths you can still set up.
|
||||
</div>
|
||||
</q-list>
|
||||
|
||||
<!-- backup file -->
|
||||
<q-list class="sattle-card q-mb-md" bordered>
|
||||
<q-item-label header class="text-primary text-weight-bold"> Backup file </q-item-label>
|
||||
<div class="q-pa-md q-pt-sm">
|
||||
<div class="text-body2 text-grey-4 q-mb-sm">
|
||||
Downloads a JSON file with this wallet's notes, mint list and settings. Notes are
|
||||
encrypted - nobody can read them from the file without your recovery phrase. The mint
|
||||
list and settings are readable by anyone who opens the file.
|
||||
</div>
|
||||
<div v-if="wallet.encrypted" class="text-body2 text-grey-4 q-mb-md">
|
||||
This wallet is password-protected, so the file also carries your wallet key, encrypted
|
||||
with your password - the file alone restores a device completely.
|
||||
</div>
|
||||
<q-banner v-else dense rounded class="sattle-card text-warning q-mb-md">
|
||||
<template #avatar>
|
||||
<q-icon name="warning" color="warning" />
|
||||
</template>
|
||||
This wallet has no password, so the file does NOT include your wallet key - restoring
|
||||
takes this file plus your recovery phrase.
|
||||
</q-banner>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
text-color="dark"
|
||||
icon="download"
|
||||
label="Download backup file"
|
||||
class="full-width"
|
||||
@click="downloadBackupFile"
|
||||
/>
|
||||
</div>
|
||||
</q-list>
|
||||
|
||||
<!-- nostr backup -->
|
||||
<q-list class="sattle-card q-mb-md" bordered>
|
||||
<q-item-label header class="text-primary text-weight-bold"> Nostr backup </q-item-label>
|
||||
<div class="q-pa-md q-pt-sm">
|
||||
<div class="row items-center justify-between q-mb-sm">
|
||||
<div class="text-body2 text-grey-4 col q-pr-md">
|
||||
Keeps an encrypted copy of your notes, mints and settings on public nostr relays. Only
|
||||
your recovery phrase can decrypt them.
|
||||
</div>
|
||||
<q-toggle
|
||||
:model-value="nostr.enabled"
|
||||
color="primary"
|
||||
aria-label="Enable nostr backup"
|
||||
@update:model-value="nostr.setEnabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="nostr.enabled">
|
||||
<q-banner dense rounded class="sattle-card text-warning q-mb-md">
|
||||
<template #avatar>
|
||||
<q-icon name="devices" color="warning" />
|
||||
</template>
|
||||
Built for one device at a time: the newest backup replaces the older one. If you run
|
||||
two devices with the same phrase, the last one to back up wins.
|
||||
</q-banner>
|
||||
|
||||
<div class="text-caption text-grey-5 q-mb-xs">Backup address</div>
|
||||
<div class="row items-center q-mb-md no-wrap">
|
||||
<code class="backup-pubkey text-grey-4 ellipsis">{{ nostr.pubkey }}</code>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
round
|
||||
size="sm"
|
||||
color="primary"
|
||||
icon="content_copy"
|
||||
aria-label="Copy backup address"
|
||||
@click="copyPubkey"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="text-caption text-grey-5 q-mb-xs">Relays</div>
|
||||
<RelaysEditor v-model="relayModel" />
|
||||
|
||||
<div class="row q-gutter-sm q-mt-md">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
text-color="dark"
|
||||
label="Back up now"
|
||||
class="col"
|
||||
:loading="backupBusy"
|
||||
:disable="nostr.relays.length === 0"
|
||||
@click="backUpNow"
|
||||
/>
|
||||
<q-btn
|
||||
outline
|
||||
color="primary"
|
||||
label="Restore from nostr"
|
||||
class="col"
|
||||
:loading="restoreBusy"
|
||||
:disable="nostr.relays.length === 0"
|
||||
@click="restoreFromNostrAction"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="nostr.lastPublishAt" class="text-caption text-grey-5 q-mt-sm">
|
||||
Last backup: {{ new Date(nostr.lastPublishAt).toLocaleString() }}
|
||||
</div>
|
||||
<div v-if="nostr.lastError" class="text-caption text-negative q-mt-sm">
|
||||
{{ nostr.lastError }}
|
||||
</div>
|
||||
<div v-if="restoreSummary" class="text-caption text-positive q-mt-sm">
|
||||
{{ restoreSummary }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</q-list>
|
||||
</template>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { copyToClipboard, useQuasar } from 'quasar';
|
||||
|
||||
import { buildBackup } from '@/lnurlcash/storage';
|
||||
import { useWalletStore } from '@/stores/wallet';
|
||||
import { useNostrBackupStore } from '@/stores/nostrBackup';
|
||||
import RelaysEditor from '@/components/RelaysEditor.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const $q = useQuasar();
|
||||
const wallet = useWalletStore();
|
||||
const nostr = useNostrBackupStore();
|
||||
|
||||
const toast = (type: 'positive' | 'negative', message: string): void => {
|
||||
if (typeof $q.notify === 'function') {
|
||||
$q.notify({ type, message, position: 'top', timeout: 3000 });
|
||||
}
|
||||
};
|
||||
|
||||
const errorMessage = (err: unknown): string =>
|
||||
err instanceof Error ? err.message : 'Something went wrong.';
|
||||
|
||||
// ---- backup file ----
|
||||
const downloadBackupFile = () => {
|
||||
const backup = buildBackup();
|
||||
const blob = new Blob([JSON.stringify(backup, null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = `sattle-backup-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// ---- nostr backup ----
|
||||
const relayModel = computed({
|
||||
get: () => nostr.relays,
|
||||
set: (relays: string[]) => nostr.setRelays(relays),
|
||||
});
|
||||
|
||||
const copyPubkey = () => {
|
||||
if (!nostr.pubkey) return;
|
||||
void copyToClipboard(nostr.pubkey).then(() => toast('positive', 'Backup address copied.'));
|
||||
};
|
||||
|
||||
const backupBusy = ref(false);
|
||||
const restoreBusy = ref(false);
|
||||
const restoreSummary = ref('');
|
||||
|
||||
const backUpNow = async () => {
|
||||
backupBusy.value = true;
|
||||
restoreSummary.value = '';
|
||||
try {
|
||||
await nostr.backupNow();
|
||||
toast('positive', 'Backup published to your relays.');
|
||||
} catch (err) {
|
||||
toast('negative', errorMessage(err));
|
||||
} finally {
|
||||
backupBusy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const restoreFromNostrAction = async () => {
|
||||
restoreBusy.value = true;
|
||||
restoreSummary.value = '';
|
||||
try {
|
||||
const result = await nostr.restore();
|
||||
if (result.found.length === 0) {
|
||||
restoreSummary.value = 'No backup found for this wallet on your relays.';
|
||||
return;
|
||||
}
|
||||
const parts = [
|
||||
`${result.added} note(s) added, ${result.skipped} already present`,
|
||||
`${result.trustedMintsAdded} mint(s) added`,
|
||||
];
|
||||
if (result.settingsRestored) parts.push('settings restored');
|
||||
restoreSummary.value = `Restored: ${parts.join(', ')}.`;
|
||||
toast('positive', 'Backup restored.');
|
||||
} catch (err) {
|
||||
toast('negative', errorMessage(err));
|
||||
} finally {
|
||||
restoreBusy.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.backup-pubkey {
|
||||
font-size: 0.75rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<q-page class="q-pa-md">
|
||||
<div class="row items-center q-mb-md">
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
round
|
||||
color="primary"
|
||||
icon="arrow_back"
|
||||
aria-label="Back"
|
||||
@click="router.push('/settings')"
|
||||
/>
|
||||
<div class="text-h5 text-weight-bold text-primary q-ml-sm">Security</div>
|
||||
</div>
|
||||
|
||||
<!-- passkeys -->
|
||||
<q-list class="sattle-card q-mb-md" bordered separator>
|
||||
<q-item-label header class="text-primary text-weight-bold">Passkeys</q-item-label>
|
||||
|
||||
<q-item v-if="supported === null">
|
||||
<q-item-section class="text-grey-5">Checking passkey support…</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item v-else-if="supported === false">
|
||||
<q-item-section>
|
||||
<q-item-label class="text-grey-3">Passkeys aren't available here</q-item-label>
|
||||
<q-item-label caption class="text-grey-5" style="white-space: normal">
|
||||
This browser has no passkey authenticator (like Touch ID, Windows Hello or Android
|
||||
biometrics) with the encryption support sattle needs. Your password unlock keeps working
|
||||
- nothing to do.
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<template v-else>
|
||||
<q-item v-if="wallet.state !== 'unlocked'">
|
||||
<q-item-section class="text-grey-5">
|
||||
Unlock your wallet first - adding a passkey needs the wallet's key in memory.
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<template v-else>
|
||||
<q-item v-if="!slots.length">
|
||||
<q-item-section class="text-grey-5">
|
||||
No passkeys yet. A passkey lets you unlock this wallet with your device's screen lock
|
||||
instead of typing the password.
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item v-for="slot in slots" :key="slot.credentialId">
|
||||
<q-item-section>
|
||||
<q-item-label class="text-grey-3">{{ slot.name || 'Passkey' }}</q-item-label>
|
||||
<q-item-label caption class="text-grey-5">
|
||||
Added {{ new Date(slot.createdAt).toLocaleDateString() }}
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-btn flat dense no-caps color="negative" label="Remove" @click="askRemove(slot)" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<div class="q-pa-md">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
text-color="dark"
|
||||
icon="fingerprint"
|
||||
label="Add a passkey"
|
||||
class="full-width"
|
||||
:loading="registerBusy"
|
||||
@click="askRegister"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</q-list>
|
||||
|
||||
<!-- auto-lock -->
|
||||
<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>
|
||||
<q-item-section>
|
||||
<q-item-label class="text-grey-3"> Locks after 5 minutes without activity </q-item-label>
|
||||
<q-item-label caption class="text-grey-5" style="white-space: normal">
|
||||
Applies when your wallet is password-protected. You get a 30-second warning with a "stay
|
||||
unlocked" option before it locks. The duration isn't configurable yet.
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
|
||||
<q-banner v-if="banner" dense class="bg-negative text-white rounded-borders q-mb-md">
|
||||
{{ banner }}
|
||||
</q-banner>
|
||||
|
||||
<!-- add-passkey dialog: name it, then the authenticator ceremony runs -->
|
||||
<q-dialog v-model="registering">
|
||||
<q-card class="sattle-card q-pa-lg">
|
||||
<div class="text-h6 text-primary q-mb-sm">Add a passkey</div>
|
||||
<div class="text-body2 text-grey-4 q-mb-md">
|
||||
Your device will ask for its screen lock. The passkey only ever unlocks this wallet on
|
||||
this device - it never leaves it.
|
||||
</div>
|
||||
<q-input
|
||||
v-model="registerName"
|
||||
dark
|
||||
outlined
|
||||
color="primary"
|
||||
label="Name (optional)"
|
||||
placeholder="e.g. laptop"
|
||||
autocomplete="off"
|
||||
class="q-mb-md"
|
||||
/>
|
||||
<div class="row q-gutter-sm justify-end">
|
||||
<q-btn v-close-popup flat no-caps color="grey-5" label="Cancel" />
|
||||
<q-btn
|
||||
unelevated
|
||||
no-caps
|
||||
color="primary"
|
||||
text-color="dark"
|
||||
label="Create passkey"
|
||||
:loading="registerBusy"
|
||||
@click="doRegister"
|
||||
/>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- remove confirmation -->
|
||||
<q-dialog v-model="confirmingRemove">
|
||||
<q-card class="sattle-card q-pa-lg">
|
||||
<div class="text-h6 text-primary q-mb-sm">Remove passkey</div>
|
||||
<div class="text-body2 text-grey-4 q-mb-md">
|
||||
Remove <strong>{{ removeTarget?.name || 'this passkey' }}</strong
|
||||
>? It will no longer unlock this wallet. You can remove it from your device's passkey list
|
||||
separately - the wallet can't do that for you.
|
||||
</div>
|
||||
<div class="row q-gutter-sm justify-end">
|
||||
<q-btn v-close-popup flat no-caps color="grey-5" label="Cancel" />
|
||||
<q-btn
|
||||
unelevated
|
||||
no-caps
|
||||
color="negative"
|
||||
text-color="white"
|
||||
label="Remove"
|
||||
@click="doRemove"
|
||||
/>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useQuasar } from 'quasar';
|
||||
|
||||
import type { PasskeySlot } from '@/lnurlcash/passkeys';
|
||||
import {
|
||||
passkeySupported,
|
||||
readPasskeySlots,
|
||||
registerPasskey,
|
||||
removePasskey,
|
||||
} from '@/lnurlcash/passkeys';
|
||||
import { useWalletStore } from '@/stores/wallet';
|
||||
|
||||
const router = useRouter();
|
||||
const $q = useQuasar();
|
||||
const wallet = useWalletStore();
|
||||
|
||||
const toast = (type: 'positive' | 'negative', message: string): void => {
|
||||
if (typeof $q.notify === 'function') {
|
||||
$q.notify({ type, message, position: 'top', timeout: 3000 });
|
||||
}
|
||||
};
|
||||
|
||||
const banner = ref('');
|
||||
|
||||
// null while the async probe runs - the support answer decides which of
|
||||
// the three states (checking / unsupported / manage) renders
|
||||
const supported = ref<boolean | null>(null);
|
||||
const slots = ref<PasskeySlot[]>([]);
|
||||
|
||||
onMounted(async () => {
|
||||
supported.value = await passkeySupported();
|
||||
slots.value = readPasskeySlots();
|
||||
});
|
||||
|
||||
// ---- register ----
|
||||
const registering = ref(false);
|
||||
const registerName = ref('');
|
||||
const registerBusy = ref(false);
|
||||
|
||||
const askRegister = () => {
|
||||
banner.value = '';
|
||||
registerName.value = '';
|
||||
registering.value = true;
|
||||
};
|
||||
|
||||
const doRegister = async () => {
|
||||
registerBusy.value = true;
|
||||
banner.value = '';
|
||||
try {
|
||||
const name = registerName.value.trim();
|
||||
await registerPasskey(wallet.requireLinkingKey(), name ? { name } : {});
|
||||
slots.value = readPasskeySlots();
|
||||
registering.value = false;
|
||||
toast('positive', 'Passkey added.');
|
||||
} catch (err) {
|
||||
banner.value = err instanceof Error ? err.message : 'Could not add that passkey.';
|
||||
registering.value = false;
|
||||
} finally {
|
||||
registerBusy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// ---- remove ----
|
||||
const confirmingRemove = ref(false);
|
||||
const removeTarget = ref<PasskeySlot | null>(null);
|
||||
|
||||
const askRemove = (slot: PasskeySlot) => {
|
||||
banner.value = '';
|
||||
removeTarget.value = slot;
|
||||
confirmingRemove.value = true;
|
||||
};
|
||||
|
||||
const doRemove = async () => {
|
||||
confirmingRemove.value = false;
|
||||
if (!removeTarget.value) return;
|
||||
await removePasskey(removeTarget.value.credentialId);
|
||||
slots.value = readPasskeySlots();
|
||||
toast('positive', 'Passkey removed.');
|
||||
};
|
||||
</script>
|
||||
@@ -50,7 +50,13 @@ const router = useRouter();
|
||||
type SettingsItem = { label: string; to?: string };
|
||||
|
||||
const groups: { label: string; items: SettingsItem[] }[] = [
|
||||
{ label: 'Wallet', items: [{ label: 'Backup' }, { label: 'Security' }] },
|
||||
{
|
||||
label: 'Wallet',
|
||||
items: [
|
||||
{ label: 'Backup', to: '/settings/backup' },
|
||||
{ label: 'Security', to: '/settings/security' },
|
||||
],
|
||||
},
|
||||
{ label: 'Connections', items: [{ label: 'Nostr Wallet Connect' }, { label: 'Nostr' }] },
|
||||
{
|
||||
label: 'Mints',
|
||||
|
||||
+183
-5
@@ -32,6 +32,7 @@
|
||||
{ label: 'Create new', value: 'create' },
|
||||
{ label: 'Restore seed', value: 'restore' },
|
||||
{ label: 'Backup file', value: 'backup' },
|
||||
{ label: 'Nostr backup', value: 'nostr' },
|
||||
]"
|
||||
/>
|
||||
|
||||
@@ -199,6 +200,107 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- restore from nostr backup -->
|
||||
<div v-else-if="tab === 'nostr'">
|
||||
<div class="text-body1 q-mb-md">
|
||||
If this wallet used nostr backup before, your notes, mints and settings are
|
||||
waiting on your relays - encrypted so only your recovery phrase can read
|
||||
them.
|
||||
</div>
|
||||
<q-input
|
||||
v-model="nostrPhrase"
|
||||
type="textarea"
|
||||
rows="3"
|
||||
dark
|
||||
outlined
|
||||
color="primary"
|
||||
label="Your 12-word recovery phrase"
|
||||
placeholder="twelve words separated by spaces"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
class="q-mb-md"
|
||||
/>
|
||||
<div class="text-caption text-grey-5 q-mb-xs">Relays to look on</div>
|
||||
<RelaysEditor v-model="nostrRelays" />
|
||||
|
||||
<div v-if="nostrError" class="text-negative q-my-sm">{{ nostrError }}</div>
|
||||
|
||||
<template v-if="!nostrFound">
|
||||
<div v-if="nostrLooked" class="text-warning q-my-sm">
|
||||
No backup found for this phrase on those relays. Check the phrase and the
|
||||
relay list - or restore from a backup file instead.
|
||||
</div>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
text-color="dark"
|
||||
label="Look for a backup"
|
||||
class="full-width q-mt-md"
|
||||
:loading="nostrBusy"
|
||||
:disable="!nostrPhraseValid || nostrRelays.length === 0"
|
||||
@click="lookForNostrBackup"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="text-body2 text-grey-4 q-my-md">
|
||||
Found a backup: {{ nostrFound.notes }} note(s), {{ nostrFound.mints }}
|
||||
mint(s)<template v-if="nostrFound.settings">, settings</template>.
|
||||
</div>
|
||||
<div class="text-caption text-grey-5 q-mb-sm">
|
||||
Password (optional) — encrypts your wallet on this device and enables
|
||||
locking. Minimum {{ MIN_PASSWORD_LENGTH }} characters. Leave empty to
|
||||
store unencrypted.
|
||||
</div>
|
||||
<q-input
|
||||
v-model="nostrPassword"
|
||||
type="password"
|
||||
dark
|
||||
outlined
|
||||
color="primary"
|
||||
label="Password"
|
||||
autocomplete="new-password"
|
||||
class="q-mb-sm"
|
||||
/>
|
||||
<q-input
|
||||
v-if="nostrPassword !== ''"
|
||||
v-model="nostrPasswordConfirm"
|
||||
type="password"
|
||||
dark
|
||||
outlined
|
||||
color="primary"
|
||||
label="Confirm password"
|
||||
autocomplete="new-password"
|
||||
class="q-mb-sm"
|
||||
/>
|
||||
<div
|
||||
v-if="nostrPassword !== '' && nostrPassword.length < MIN_PASSWORD_LENGTH"
|
||||
class="text-warning text-caption q-mb-sm"
|
||||
>
|
||||
At least {{ MIN_PASSWORD_LENGTH }} characters.
|
||||
</div>
|
||||
<div
|
||||
v-if="nostrPasswordConfirm !== '' && nostrPassword !== nostrPasswordConfirm"
|
||||
class="text-warning text-caption q-mb-sm"
|
||||
>
|
||||
Passwords do not match.
|
||||
</div>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
text-color="dark"
|
||||
label="Restore this backup"
|
||||
class="full-width q-mt-sm"
|
||||
:loading="nostrBusy"
|
||||
:disable="!passwordValid(nostrPassword, nostrPasswordConfirm)"
|
||||
@click="restoreNostrBackup"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- restore from backup file -->
|
||||
<div v-else>
|
||||
<div class="text-body1 q-mb-md">
|
||||
@@ -269,13 +371,22 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { Notify } from 'quasar';
|
||||
|
||||
import { deriveWalletLinkingKey, isValidSeedPhrase } from '@/lnurlcash/keys';
|
||||
import {
|
||||
backupPubkey,
|
||||
deriveBackupKey,
|
||||
fetchBackup,
|
||||
restoreFromNostr,
|
||||
} from '@/lnurlcash/nostrBackup';
|
||||
import { useWalletStore } from '@/stores/wallet';
|
||||
import { DEFAULT_NOSTR_RELAYS } from '@/stores/nostrBackup';
|
||||
import { applyBackup, MAX_BACKUP_FILE_BYTES } from '@/lnurlcash/storage';
|
||||
import type { RestoreResult } from '@/lnurlcash/storage';
|
||||
import RelaysEditor from '@/components/RelaysEditor.vue';
|
||||
|
||||
// the key's ciphertext sits in local storage AND travels inside every backup
|
||||
// file by design, so this password is the only thing between an offline
|
||||
@@ -283,14 +394,16 @@ import type { RestoreResult } from '@/lnurlcash/storage';
|
||||
// no password at all
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
type Tab = 'create' | 'restore' | 'backup';
|
||||
type Tab = 'create' | 'restore' | 'backup' | 'nostr';
|
||||
|
||||
const wallet = useWalletStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const tab = ref<Tab>(
|
||||
route.query.tab === 'restore' || route.query.tab === 'backup' ? route.query.tab : 'create',
|
||||
route.query.tab === 'restore' || route.query.tab === 'backup' || route.query.tab === 'nostr'
|
||||
? route.query.tab
|
||||
: 'create',
|
||||
);
|
||||
|
||||
const errorMessage = (err: unknown): string =>
|
||||
@@ -396,13 +509,78 @@ const restoreFromBackupFile = async (event: Event) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ---- restore from nostr backup ----
|
||||
// phrase in -> derive the linking key (and from it the backup key) -> fetch
|
||||
// the encrypted parts -> preview what was found -> applyBackup via
|
||||
// restoreFromNostr, then install the key through the ordinary seed path
|
||||
const nostrPhrase = ref('');
|
||||
const nostrRelays = ref<string[]>([...DEFAULT_NOSTR_RELAYS]);
|
||||
const nostrPassword = ref('');
|
||||
const nostrPasswordConfirm = ref('');
|
||||
const nostrBusy = ref(false);
|
||||
const nostrError = ref('');
|
||||
const nostrLooked = ref(false);
|
||||
const nostrFound = ref<{ notes: number; mints: number; settings: boolean } | null>(null);
|
||||
|
||||
const nostrPhraseValid = computed(() => isValidSeedPhrase(nostrPhrase.value));
|
||||
|
||||
const nostrLinkingKey = (): Uint8Array =>
|
||||
deriveWalletLinkingKey(nostrPhrase.value.trim().toLowerCase());
|
||||
|
||||
const lookForNostrBackup = async () => {
|
||||
nostrBusy.value = true;
|
||||
nostrError.value = '';
|
||||
nostrFound.value = null;
|
||||
nostrLooked.value = false;
|
||||
try {
|
||||
const secretKey = deriveBackupKey(nostrLinkingKey());
|
||||
const parts = await fetchBackup(backupPubkey(secretKey), nostrRelays.value, {
|
||||
secretKey,
|
||||
});
|
||||
nostrLooked.value = true;
|
||||
if (parts.notes || parts.mints || parts.settings) {
|
||||
nostrFound.value = {
|
||||
notes: parts.notes?.length ?? 0,
|
||||
mints: parts.mints?.length ?? 0,
|
||||
settings: parts.settings !== undefined,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
nostrError.value = errorMessage(err);
|
||||
} finally {
|
||||
nostrBusy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const restoreNostrBackup = async () => {
|
||||
nostrBusy.value = true;
|
||||
nostrError.value = '';
|
||||
try {
|
||||
const linkingKey = nostrLinkingKey();
|
||||
// merge the fetched parts into local storage FIRST (the same applyBackup
|
||||
// path as a file restore), then installing the seed activates the wallet
|
||||
// and loads what was just restored
|
||||
await restoreFromNostr(linkingKey, nostrRelays.value);
|
||||
await wallet.restoreFromSeed(
|
||||
nostrPhrase.value.trim().toLowerCase(),
|
||||
nostrPassword.value || undefined,
|
||||
);
|
||||
Notify.create({ type: 'positive', message: 'Backup restored - welcome back.' });
|
||||
void router.push('/');
|
||||
} catch (err) {
|
||||
nostrError.value = errorMessage(err);
|
||||
Notify.create({ type: 'negative', message: nostrError.value });
|
||||
} finally {
|
||||
nostrBusy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// the backup installed its own key into local storage behind the wallet
|
||||
// store's back - a full reload re-runs the boot sequence so the app comes up
|
||||
// against the restored key (unlock screen if it was password-encrypted)
|
||||
const proceedWithBackupKey = () => {
|
||||
window.location.assign('/');
|
||||
};
|
||||
</script>
|
||||
};</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.onboarding-panel {
|
||||
|
||||
@@ -7,6 +7,14 @@ const routes: RouteRecordRaw[] = [
|
||||
children: [
|
||||
{ path: '', component: () => import('@/pages/IndexPage.vue') },
|
||||
{ path: 'settings', component: () => import('@/pages/SettingsPage.vue') },
|
||||
{
|
||||
path: 'settings/backup',
|
||||
component: () => import('@/pages/BackupPage.vue'),
|
||||
},
|
||||
{
|
||||
path: 'settings/security',
|
||||
component: () => import('@/pages/SecurityPage.vue'),
|
||||
},
|
||||
{
|
||||
path: 'settings/mints',
|
||||
component: () => import('@/pages/ManageMintsPage.vue'),
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
import type { BackupPartPayload, BackupPublisher } from '@/lnurlcash/nostrBackup';
|
||||
import {
|
||||
backupPubkey,
|
||||
createBackupPublisher,
|
||||
deriveBackupKey,
|
||||
publishBackup,
|
||||
restoreFromNostr,
|
||||
} from '@/lnurlcash/nostrBackup';
|
||||
import type { NostrRestoreResult } from '@/lnurlcash/nostrBackup';
|
||||
import { loadSettings, persistSettings, readEncryptedBearers } from '@/lnurlcash/storage';
|
||||
import { readTrustedMints } from '@/lnurlcash/trustedMints';
|
||||
import { useWalletStore } from './wallet';
|
||||
import { useMintsStore } from './mints';
|
||||
|
||||
// a small set of well-known, long-lived relays - editable in the UI, and
|
||||
// only persisted once the holder actually changes them (absent = defaults)
|
||||
export const DEFAULT_NOSTR_RELAYS: readonly string[] = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.primal.net',
|
||||
];
|
||||
|
||||
// bursts of edits (a receive plus its mint trust plus a settings change)
|
||||
// collapse into one publish of the final state per quiet window
|
||||
const PUBLISH_DEBOUNCE_MS = 5000;
|
||||
|
||||
export const normalizeRelay = (input: string): string => {
|
||||
const relay = input.trim().toLowerCase().replace(/\/+$/, '');
|
||||
if (!/^wss?:\/\/\S+$/.test(relay)) {
|
||||
throw new Error('A relay is a WebSocket URL, like wss://relay.example.com.');
|
||||
}
|
||||
return relay;
|
||||
};
|
||||
|
||||
// The nostr-backup control surface: the enabled/relays settings (persisted
|
||||
// in wallet settings), the debounced publisher wired to store changes while
|
||||
// the wallet is unlocked, and the manual back-up/restore actions. The
|
||||
// engine (lnurlcash/nostrBackup.ts) stays framework-free; everything
|
||||
// reactive lives here.
|
||||
export const useNostrBackupStore = defineStore('nostrBackup', () => {
|
||||
const wallet = useWalletStore();
|
||||
const mints = useMintsStore();
|
||||
|
||||
const settings = loadSettings();
|
||||
const enabled = ref(settings.nostrBackupEnabled ?? false);
|
||||
const relays = ref<string[]>(
|
||||
settings.nostrBackupRelays && settings.nostrBackupRelays.length > 0
|
||||
? [...settings.nostrBackupRelays]
|
||||
: [...DEFAULT_NOSTR_RELAYS],
|
||||
);
|
||||
|
||||
// feedback for the backup page: when the last publish went out, or why
|
||||
// the last attempt failed (a debounced publish has no caller to throw to)
|
||||
const lastPublishAt = ref<number | null>(null);
|
||||
const lastError = ref('');
|
||||
|
||||
// the backup identity is derived from the linking key, so it only exists
|
||||
// while unlocked - locked renders must never touch requireLinkingKey
|
||||
const pubkey = computed(() =>
|
||||
wallet.state === 'unlocked' ? backupPubkey(deriveBackupKey(wallet.requireLinkingKey())) : null,
|
||||
);
|
||||
|
||||
const currentPayload = (): BackupPartPayload => ({
|
||||
notes: readEncryptedBearers(),
|
||||
mints: readTrustedMints(),
|
||||
settings: loadSettings(),
|
||||
});
|
||||
|
||||
const publishNow = async (): Promise<void> => {
|
||||
const secretKey = deriveBackupKey(wallet.requireLinkingKey());
|
||||
await publishBackup(secretKey, currentPayload(), relays.value);
|
||||
lastPublishAt.value = Date.now();
|
||||
};
|
||||
|
||||
// ---- debounced publishing lifecycle ----
|
||||
// wired while (unlocked AND enabled) only; both the watchers and the
|
||||
// publisher are torn down the moment either flips, so a locked wallet
|
||||
// never schedules anything and holds no key-material closure
|
||||
let publisher: BackupPublisher | null = null;
|
||||
let stopWatchers: (() => void) | null = null;
|
||||
|
||||
const start = (): void => {
|
||||
if (publisher) return;
|
||||
publisher = createBackupPublisher({
|
||||
delayMs: PUBLISH_DEBOUNCE_MS,
|
||||
publish: async (parts) => {
|
||||
await publishBackup(deriveBackupKey(wallet.requireLinkingKey()), parts, relays.value);
|
||||
lastPublishAt.value = Date.now();
|
||||
lastError.value = '';
|
||||
},
|
||||
onError: (error) => {
|
||||
lastError.value = error instanceof Error ? error.message : 'Backup publish failed.';
|
||||
},
|
||||
});
|
||||
const schedule = () => publisher?.schedule(currentPayload());
|
||||
const stops = [
|
||||
watch(() => wallet.bearers, schedule),
|
||||
watch(() => mints.mints, schedule),
|
||||
watch(() => mints.defaultMint, schedule),
|
||||
];
|
||||
stopWatchers = () => stops.forEach((stop) => stop());
|
||||
// an initial publish on (re)activation, so enabling backup on a wallet
|
||||
// that then sits idle still lands a backup
|
||||
schedule();
|
||||
};
|
||||
|
||||
const stop = (): void => {
|
||||
stopWatchers?.();
|
||||
stopWatchers = null;
|
||||
publisher?.cancel();
|
||||
publisher = null;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [wallet.state, enabled.value] as const,
|
||||
([state, on]) => {
|
||||
if (state === 'unlocked' && on) start();
|
||||
else stop();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// ---- settings ----
|
||||
const setEnabled = (value: boolean): void => {
|
||||
enabled.value = value;
|
||||
persistSettings({ ...loadSettings(), nostrBackupEnabled: value });
|
||||
};
|
||||
|
||||
const setRelays = (list: string[]): void => {
|
||||
relays.value = list;
|
||||
persistSettings({ ...loadSettings(), nostrBackupRelays: list });
|
||||
};
|
||||
|
||||
// ---- manual actions (the backup page buttons) ----
|
||||
const backupNow = async (): Promise<void> => {
|
||||
lastError.value = '';
|
||||
try {
|
||||
await publishNow();
|
||||
} catch (error) {
|
||||
lastError.value = error instanceof Error ? error.message : 'Backup publish failed.';
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// pulls the newest backup for THIS wallet's key and merges it through the
|
||||
// same applyBackup path as a file restore, then reloads the live list
|
||||
const restore = async (): Promise<NostrRestoreResult> => {
|
||||
const result = await restoreFromNostr(wallet.requireLinkingKey(), relays.value);
|
||||
await wallet.reloadBearers();
|
||||
return result;
|
||||
};
|
||||
|
||||
return {
|
||||
enabled,
|
||||
relays,
|
||||
pubkey,
|
||||
lastPublishAt,
|
||||
lastError,
|
||||
setEnabled,
|
||||
setRelays,
|
||||
backupNow,
|
||||
restore,
|
||||
};
|
||||
});
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
clearTrustedMints
|
||||
} from '@/lnurlcash/trustedMints'
|
||||
import {clearSettings} from '@/lnurlcash/storage'
|
||||
import {unlockWithPasskey as unlockWithPasskeyEngine} from '@/lnurlcash/passkeys'
|
||||
import {msatToSats} from '@/lnurlcash/units'
|
||||
import {useActivityStore} from './activity'
|
||||
|
||||
@@ -56,6 +57,10 @@ export const useWalletStore = defineStore('wallet', () => {
|
||||
const bearers = ref<Bearer[]>([])
|
||||
const pubkey = ref<string | null>(null)
|
||||
let aesKey: CryptoKey | null = null
|
||||
// the linking key itself, only while unlocked - needed by backup/passkey
|
||||
// operations (nostrBackup derives the backup key from it, passkey
|
||||
// registration wraps it). Never exposed reactively; cleared on lock/forget
|
||||
let currentLinkingKey: Uint8Array | null = null
|
||||
|
||||
// ---- idle auto-lock bookkeeping ----
|
||||
let lastActivity = Date.now()
|
||||
@@ -97,6 +102,7 @@ export const useWalletStore = defineStore('wallet', () => {
|
||||
// just auto-unlock again, so the UI only offers Lock when encrypted
|
||||
if (!savedKeyIsEncrypted()) return
|
||||
aesKey = null
|
||||
currentLinkingKey = null
|
||||
pubkey.value = null
|
||||
bearers.value = []
|
||||
useActivityStore().unload()
|
||||
@@ -145,6 +151,7 @@ export const useWalletStore = defineStore('wallet', () => {
|
||||
const activate = async (linkingKey: Uint8Array) => {
|
||||
const key = await deriveBearerAesKey(linkingKey)
|
||||
aesKey = key
|
||||
currentLinkingKey = linkingKey
|
||||
pubkey.value = linkingPubKeyHex(linkingKey)
|
||||
const loaded = await loadBearers(key)
|
||||
bearers.value = loaded
|
||||
@@ -193,6 +200,12 @@ export const useWalletStore = defineStore('wallet', () => {
|
||||
await activate(linkingKey)
|
||||
}
|
||||
|
||||
// passkey unlock (passkeys.ts): the ceremony unwraps the SAME linking key
|
||||
// the password path protects, so activation is identical either way
|
||||
const unlockWithPasskey = async (): Promise<void> => {
|
||||
await activate(await unlockWithPasskeyEngine())
|
||||
}
|
||||
|
||||
// app-start entry point (boot/wallet.ts): a plaintext-stored key unlocks
|
||||
// without a password; an encrypted one waits on the unlock screen
|
||||
const init = async (): Promise<void> => {
|
||||
@@ -214,6 +227,7 @@ export const useWalletStore = defineStore('wallet', () => {
|
||||
clearSettings()
|
||||
useActivityStore().unloadAndClear()
|
||||
aesKey = null
|
||||
currentLinkingKey = null
|
||||
pubkey.value = null
|
||||
bearers.value = []
|
||||
stopIdleWatch()
|
||||
@@ -225,6 +239,14 @@ export const useWalletStore = defineStore('wallet', () => {
|
||||
return aesKey
|
||||
}
|
||||
|
||||
// narrow accessor for the operations that need the key material itself
|
||||
// (nostr backup key derivation, passkey registration) - never reactive,
|
||||
// throws when locked, so callers can't accidentally hold a stale key
|
||||
const requireLinkingKey = (): Uint8Array => {
|
||||
if (!currentLinkingKey) throw new Error('Wallet is locked.')
|
||||
return currentLinkingKey
|
||||
}
|
||||
|
||||
// the one entry point for new notes (minted, received, carved outputs):
|
||||
// persists first, then updates state. Holding a bearer from a mint
|
||||
// trusts it by default - this is the one path that never asks (see
|
||||
@@ -305,10 +327,12 @@ export const useWalletStore = defineStore('wallet', () => {
|
||||
create,
|
||||
restoreFromSeed,
|
||||
unlock,
|
||||
unlockWithPasskey,
|
||||
lock,
|
||||
init,
|
||||
forgetWallet,
|
||||
postponeLock,
|
||||
requireLinkingKey,
|
||||
addBearers,
|
||||
updateBearer,
|
||||
markSpent,
|
||||
|
||||
Reference in New Issue
Block a user