diff --git a/src/boot/wallet.ts b/src/boot/wallet.ts
index 6df9724..acea818 100644
--- a/src/boot/wallet.ts
+++ b/src/boot/wallet.ts
@@ -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();
});
diff --git a/src/components/RelaysEditor.vue b/src/components/RelaysEditor.vue
new file mode 100644
index 0000000..a1512b0
--- /dev/null
+++ b/src/components/RelaysEditor.vue
@@ -0,0 +1,78 @@
+
+
+
+
+ {{ relay }}
+
+
+
+ No relays configured - a backup has nowhere to go.
+
+
+
+
+
+
+
diff --git a/src/components/UnlockForm.vue b/src/components/UnlockForm.vue
index 9b6a355..7bbba3d 100644
--- a/src/components/UnlockForm.vue
+++ b/src/components/UnlockForm.vue
@@ -39,6 +39,17 @@
@click="unlock"
/>
+
+
();
@@ -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;
diff --git a/src/lnurlcash/storage/settings.ts b/src/lnurlcash/storage/settings.ts
index e61a1ad..fa44848 100644
--- a/src/lnurlcash/storage/settings.ts
+++ b/src/lnurlcash/storage/settings.ts
@@ -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
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 {}
diff --git a/src/pages/BackupPage.vue b/src/pages/BackupPage.vue
new file mode 100644
index 0000000..52ec6f3
--- /dev/null
+++ b/src/pages/BackupPage.vue
@@ -0,0 +1,243 @@
+
+
+
+
+
+
+ Unlock your wallet first - backup operations need the wallet's key in memory.
+
+
+
+
+
+
+ Recovery phrase
+
+ 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.
+
+
+
+
+
+ Backup file
+
+
+ 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.
+
+
+ This wallet is password-protected, so the file also carries your wallet key, encrypted
+ with your password - the file alone restores a device completely.
+
+
+
+
+
+ This wallet has no password, so the file does NOT include your wallet key - restoring
+ takes this file plus your recovery phrase.
+
+
+
+
+
+
+
+ Nostr backup
+
+
+
+ Keeps an encrypted copy of your notes, mints and settings on public nostr relays. Only
+ your recovery phrase can decrypt them.
+
+
+
+
+
+
+
+
+
+ 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.
+
+
+ Backup address
+
+ {{ nostr.pubkey }}
+
+
+
+ Relays
+
+
+
+
+
+
+
+
+ Last backup: {{ new Date(nostr.lastPublishAt).toLocaleString() }}
+
+
+ {{ nostr.lastError }}
+
+
+ {{ restoreSummary }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/SecurityPage.vue b/src/pages/SecurityPage.vue
new file mode 100644
index 0000000..f5192a6
--- /dev/null
+++ b/src/pages/SecurityPage.vue
@@ -0,0 +1,233 @@
+
+
+
+
+
+
+ Passkeys
+
+
+ Checking passkey support…
+
+
+
+
+ Passkeys aren't available here
+
+ 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.
+
+
+
+
+
+
+
+ Unlock your wallet first - adding a passkey needs the wallet's key in memory.
+
+
+
+
+
+
+ No passkeys yet. A passkey lets you unlock this wallet with your device's screen lock
+ instead of typing the password.
+
+
+
+
+ {{ slot.name || 'Passkey' }}
+
+ Added {{ new Date(slot.createdAt).toLocaleDateString() }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Auto-lock
+
+
+ Locks after 5 minutes without activity
+
+ 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.
+
+
+
+
+
+
+ {{ banner }}
+
+
+
+
+
+ Add a passkey
+
+ Your device will ask for its screen lock. The passkey only ever unlocks this wallet on
+ this device - it never leaves it.
+
+
+
+
+
+
+
+
+
+
+
+
+ Remove passkey
+
+ Remove {{ removeTarget?.name || 'this passkey' }}? 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.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/SettingsPage.vue b/src/pages/SettingsPage.vue
index d657e91..cdbe6dd 100644
--- a/src/pages/SettingsPage.vue
+++ b/src/pages/SettingsPage.vue
@@ -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',
diff --git a/src/pages/WelcomePage.vue b/src/pages/WelcomePage.vue
index 1dac0a8..e071e2e 100644
--- a/src/pages/WelcomePage.vue
+++ b/src/pages/WelcomePage.vue
@@ -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 @@
/>
+
+
+
+ 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.
+
+
+
Relays to look on
+
+
+
{{ nostrError }}
+
+
+
+ No backup found for this phrase on those relays. Check the phrase and the
+ relay list - or restore from a backup file instead.
+
+
+
+
+
+
+ Found a backup: {{ nostrFound.notes }} note(s), {{ nostrFound.mints }}
+ mint(s), settings.
+
+
+ Password (optional) — encrypts your wallet on this device and enables
+ locking. Minimum {{ MIN_PASSWORD_LENGTH }} characters. Leave empty to
+ store unencrypted.
+
+
+
+
+ At least {{ MIN_PASSWORD_LENGTH }} characters.
+
+
+ Passwords do not match.
+
+
+
+
+
@@ -269,13 +371,22 @@
+};