diff --git a/src/boot/wallet.ts b/src/boot/wallet.ts index acea818..84b4476 100644 --- a/src/boot/wallet.ts +++ b/src/boot/wallet.ts @@ -1,6 +1,7 @@ import { defineBoot } from '#q-app'; import { useWalletStore } from '@/stores/wallet'; import { useNostrBackupStore } from '@/stores/nostrBackup'; +import { useNwcStore } from '@/stores/nwc'; // Wallet lifecycle bootstrap: reflects whatever is on this device into the // wallet store at app start - a plaintext-stored key unlocks straight away, @@ -12,4 +13,7 @@ export default defineBoot(async () => { // instantiating the store arms its watchers: while the wallet is unlocked // and nostr backup is enabled, store changes schedule debounced publishes useNostrBackupStore(); + // same arming for NWC: while enabled and unlocked, the service answers + // client requests; on lock it stops and drops the key-material closure + useNwcStore(); }); diff --git a/src/components/HistoryList.vue b/src/components/HistoryList.vue index 45852d5..6de1da5 100644 --- a/src/components/HistoryList.vue +++ b/src/components/HistoryList.vue @@ -20,6 +20,7 @@ const KIND_ICONS: Record = { spent: 'check', deleted: 'delete', transfer: 'swap_horiz', + nwc: 'bolt', }; const KIND_COLORS: Record = { @@ -31,6 +32,7 @@ const KIND_COLORS: Record = { spent: 'grey-5', deleted: 'negative', transfer: 'primary', + nwc: 'primary', }; const iconFor = (kind: ActivityKind): string => KIND_ICONS[kind]; diff --git a/src/components/NwcBudgetPicker.vue b/src/components/NwcBudgetPicker.vue new file mode 100644 index 0000000..cb34b71 --- /dev/null +++ b/src/components/NwcBudgetPicker.vue @@ -0,0 +1,113 @@ + + + diff --git a/src/lnurlcash/storage/activityLog.ts b/src/lnurlcash/storage/activityLog.ts index 25f139c..4b763e7 100644 --- a/src/lnurlcash/storage/activityLog.ts +++ b/src/lnurlcash/storage/activityLog.ts @@ -2,9 +2,9 @@ // AES-GCM under the same bearer key, append-only, capped so a wallet used // for years doesn't grow localStorage without limit. -import type {EncryptedRecordParts} from '../keys' -import {encryptRecord, decryptRecord} from '../keys' -import {withStorageLock} from '../storageLock' +import type { EncryptedRecordParts } from '../keys'; +import { encryptRecord, decryptRecord } from '../keys'; +import { withStorageLock } from '../storageLock'; // `message` is the full human-readable sentence rather than structured // fields the UI reassembles, so the log stays simple to read and to extend @@ -18,77 +18,74 @@ export type ActivityKind = | 'receive' | 'spent' | 'deleted' + // a payment or mint initiated by a Nostr Wallet Connect client (M5) + | 'nwc'; export type ActivityEvent = { - id: string - kind: ActivityKind - message: string - createdAt: number -} + id: string; + kind: ActivityKind; + message: string; + createdAt: number; +}; -export type EncryptedActivityRecord = {id: string} & EncryptedRecordParts +export type EncryptedActivityRecord = { id: string } & EncryptedRecordParts; -const ACTIVITY_STORAGE_KEY = 'sattle_activity' +const ACTIVITY_STORAGE_KEY = 'sattle_activity'; // bounds how far back the log ever reaches - the oldest entries simply // roll off once this many are kept -export const MAX_ACTIVITY_ENTRIES = 500 +export const MAX_ACTIVITY_ENTRIES = 500; export const newActivityId = (): string => Array.from(crypto.getRandomValues(new Uint8Array(8))) - .map(b => b.toString(16).padStart(2, '0')) - .join('') + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); export const readEncryptedActivity = (): EncryptedActivityRecord[] => { - const raw = localStorage.getItem(ACTIVITY_STORAGE_KEY) - if (!raw) return [] + const raw = localStorage.getItem(ACTIVITY_STORAGE_KEY); + if (!raw) return []; try { - const parsed: unknown = JSON.parse(raw) - return Array.isArray(parsed) ? parsed : [] + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; } catch { - return [] + return []; } -} +}; const writeEncryptedActivity = (records: EncryptedActivityRecord[]): void => { - localStorage.setItem(ACTIVITY_STORAGE_KEY, JSON.stringify(records)) -} + localStorage.setItem(ACTIVITY_STORAGE_KEY, JSON.stringify(records)); +}; // same tolerance as loadBearers - an entry that fails to decrypt with this // key (written by a different seed) is skipped, not destroyed -export const loadActivity = async ( - aesKey: CryptoKey -): Promise => { - const events: ActivityEvent[] = [] +export const loadActivity = async (aesKey: CryptoKey): Promise => { + const events: ActivityEvent[] = []; for (const record of readEncryptedActivity()) { try { - const event = await decryptRecord>( - aesKey, - record - ) - events.push({...event, id: record.id}) + const event = await decryptRecord>(aesKey, record); + events.push({ ...event, id: record.id }); } catch { // undecryptable with this key - leave it in place } } - return events.sort((a, b) => b.createdAt - a.createdAt) -} + return events.sort((a, b) => b.createdAt - a.createdAt); +}; // append-only (the log never edits or removes a single entry, only clears // outright - see clearAllActivity) - records are stored oldest-first so // trimming to the cap is just dropping off the front export const persistActivityEvent = async ( aesKey: CryptoKey, - event: ActivityEvent + event: ActivityEvent, ): Promise => { - const {id, ...plain} = event - const parts = await encryptRecord(aesKey, plain) + const { id, ...plain } = event; + const parts = await encryptRecord(aesKey, plain); await withStorageLock(ACTIVITY_STORAGE_KEY, () => { - const records = readEncryptedActivity() - records.push({id, ...parts}) - writeEncryptedActivity(records.slice(-MAX_ACTIVITY_ENTRIES)) - }) -} + const records = readEncryptedActivity(); + records.push({ id, ...parts }); + writeEncryptedActivity(records.slice(-MAX_ACTIVITY_ENTRIES)); + }); +}; export const clearAllActivity = (): void => { - localStorage.removeItem(ACTIVITY_STORAGE_KEY) -} + localStorage.removeItem(ACTIVITY_STORAGE_KEY); +}; diff --git a/src/pages/NwcPage.vue b/src/pages/NwcPage.vue new file mode 100644 index 0000000..7a1f744 --- /dev/null +++ b/src/pages/NwcPage.vue @@ -0,0 +1,334 @@ + + + + + diff --git a/src/pages/SettingsPage.vue b/src/pages/SettingsPage.vue index cdbe6dd..1d9b378 100644 --- a/src/pages/SettingsPage.vue +++ b/src/pages/SettingsPage.vue @@ -14,8 +14,8 @@ + lands (see project plan); Wallet (M4), Connections (M5) and the + Mints group (M3) are live --> import('@/pages/MoveFundsPage.vue'), }, + { + path: 'settings/nwc', + component: () => import('@/pages/NwcPage.vue'), + }, ], }, { diff --git a/src/stores/nwc.ts b/src/stores/nwc.ts new file mode 100644 index 0000000..c3b7f94 --- /dev/null +++ b/src/stores/nwc.ts @@ -0,0 +1,222 @@ +import { ref, watch } from 'vue'; +import { defineStore } from 'pinia'; + +import type { + CreatedConnection, + NwcBudget, + NwcConnectionInfo, + NwcConnectionRecord, + NwcMethod, + NwcChangeset, + NwcService, + NwcTransport, +} from '@/lnurlcash/nwc'; +import { + createConnection, + persistNwcConnection, + readNwcConnections, + removeNwcConnection, + startService, +} from '@/lnurlcash/nwc'; +import { msatToSats } from '@/lnurlcash/units'; +import { useWalletStore } from './wallet'; +import { useMintsStore } from './mints'; +import { useActivityStore } from './activity'; + +// budget period presets the UI offers (the engine speaks raw ms) +export const NWC_PERIOD_DAY_MS = 86_400_000; +export const NWC_PERIOD_WEEK_MS = 604_800_000; + +// the engine requires a concrete max (NwcBudget.maxMsat must be > 0) - there +// is no "unlimited"; this is the generous default the create form preselects +export const NWC_DEFAULT_BUDGET: NwcBudget = { + maxMsat: 10_000 * 1000, + periodMs: NWC_PERIOD_DAY_MS, +}; + +// the enabled flag lives outside wallet settings on purpose: settings are +// part of the nostr-backup payload, and a restored device must not start +// answering payment requests before its holder opted in there +const NWC_ENABLED_KEY = 'sattle_nwc_enabled'; +const readNwcEnabled = (): boolean => localStorage.getItem(NWC_ENABLED_KEY) === 'true'; + +// e2e test hook: a fake transport so the suite never touches a real relay. +// Set before enabling; production never calls this (exposed on window only +// in dev builds, at the bottom of this file). +let transportOverride: NwcTransport | null = null; +export const setNwcTransportForTests = (transport: NwcTransport | null): void => { + transportOverride = transport; +}; + +const fingerprint = (pubkey: string): string => + pubkey.length > 18 ? `${pubkey.slice(0, 10)}…${pubkey.slice(-8)}` : pubkey; + +const formatSats = (msat: number): string => + msatToSats(msat).toLocaleString(undefined, { maximumFractionDigits: 3 }); + +// The NWC control surface: the enabled setting, the reactive connection +// list, and the service lifecycle. The engine (lnurlcash/nwc.ts) stays +// framework-free; the service runs only while (enabled AND unlocked) - the +// wallet-service keys derive from the linking key, which only exists in +// memory then (foreground-only, see the nwc.ts façade header). +export const useNwcStore = defineStore('nwc', () => { + const wallet = useWalletStore(); + const mints = useMintsStore(); + const activity = useActivityStore(); + + const enabled = ref(readNwcEnabled()); + const connections = ref(readNwcConnections()); + const running = ref(false); + // background failures (a rejected publish, a lost claim) have no caller + // to throw to - the page surfaces them here + const lastError = ref(''); + + const refresh = (): void => { + connections.value = readNwcConnections(); + }; + + // ---- changeset application ---- + // the engine hands money-moving deltas here after an op ran: new notes to + // persist, bearer ids to lock spent. Both go through the wallet store's + // one entry points (persist-then-state); failures surface as lastError + // rather than vanishing, since the engine already committed its side. + const applyChangeset = ( + changeset: NwcChangeset, + connection: NwcConnectionInfo, + method: NwcMethod, + ): void => { + const client = fingerprint(connection.record.clientPubkey); + if (method === 'pay_invoice') { + // the melt's amount, from the bearers about to be locked spent + const spentMsat = changeset.markSpent.reduce( + (sum, id) => sum + (wallet.bearers.find((b) => b.id === id)?.amount ?? 0), + 0, + ); + activity.log('nwc', `NWC client ${client} paid ${formatSats(spentMsat)} sats.`); + } + if (method === 'make_invoice' && changeset.add.length > 0) { + const mintedMsat = changeset.add.reduce((sum, note) => sum + note.amount, 0); + activity.log('nwc', `Received ${formatSats(mintedMsat)} sats via NWC client ${client}.`); + } + const onFailure = (error: unknown) => { + lastError.value = error instanceof Error ? error.message : 'Applying an NWC change failed.'; + }; + if (changeset.add.length > 0) { + void wallet.addBearers(changeset.add).catch(onFailure); + } + for (const id of changeset.markSpent) { + void wallet.markSpent(id).catch(onFailure); + } + }; + + // ---- service lifecycle ---- + // armed while (unlocked AND enabled) only; stop() closes every relay + // subscription and drops the key-material closure. startToken invalidates + // a start that is still in flight when stop (or a restart) lands. + let service: NwcService | null = null; + let startToken = 0; + + const start = async (): Promise => { + const token = ++startToken; + lastError.value = ''; + try { + const started = await startService(wallet.requireLinkingKey(), { + // only spendable notes may back an NWC payment + getBearers: () => wallet.unspentBearers, + getDefaultMint: () => mints.defaultMint, + applyChangeset, + transport: transportOverride ?? undefined, + onError: (error) => { + lastError.value = + error instanceof Error ? error.message : 'The NWC service hit an error.'; + }, + }); + if (token !== startToken) { + // stopped (or restarted) while we were subscribing + started.stop(); + return; + } + service = started; + running.value = true; + } catch (error) { + if (token === startToken) { + lastError.value = + error instanceof Error ? error.message : 'The NWC service failed to start.'; + } + } + }; + + const stop = (): void => { + startToken++; + service?.stop(); + service = null; + running.value = false; + }; + + watch( + () => [wallet.state, enabled.value] as const, + ([state, on]) => { + if (state === 'unlocked' && on) void start(); + else stop(); + }, + { immediate: true }, + ); + + // the served set is a startup snapshot, so any change to the connection + // records (create / budget edit / revoke) restarts the service to match + const restartIfRunning = (): void => { + if (!running.value) return; + stop(); + if (wallet.state === 'unlocked' && enabled.value) void start(); + }; + + // ---- settings ---- + const setEnabled = (value: boolean): void => { + enabled.value = value; + localStorage.setItem(NWC_ENABLED_KEY, String(value)); + }; + + // ---- connection management ---- + // returns the created connection INCLUDING the one-time connection + // string; the store keeps no copy of it (the client secret is never + // persisted) - the caller must show it exactly once + const create = (relays: string[], budget: NwcBudget): CreatedConnection => { + const created = createConnection(wallet.requireLinkingKey(), { relays, budget }); + refresh(); + restartIfRunning(); + return created; + }; + + const updateBudget = (clientPubkey: string, budget: NwcBudget): void => { + const record = readNwcConnections().find((r) => r.clientPubkey === clientPubkey); + if (!record) return; + persistNwcConnection({ ...record, budget }); + refresh(); + restartIfRunning(); + }; + + const revoke = (clientPubkey: string): void => { + removeNwcConnection(clientPubkey); + refresh(); + restartIfRunning(); + }; + + return { + enabled, + connections, + running, + lastError, + setEnabled, + create, + updateBudget, + revoke, + }; +}); + +// dev-only e2e hook: lets a spec inject a fake relay transport before +// enabling the service, so the suite opens no real WebSocket +if (import.meta.env.DEV && typeof window !== 'undefined') { + (window as unknown as Record).__sattleNwcTest = { + setTransport: setNwcTransportForTests, + }; +}