feat: nwc connections settings page with one-time connection strings and service lifecycle

This commit is contained in:
2026-08-20 01:20:41 +02:00
parent 33318a5150
commit 52211273b1
8 changed files with 727 additions and 45 deletions
+4
View File
@@ -1,6 +1,7 @@
import { defineBoot } from '#q-app'; import { defineBoot } from '#q-app';
import { useWalletStore } from '@/stores/wallet'; import { useWalletStore } from '@/stores/wallet';
import { useNostrBackupStore } from '@/stores/nostrBackup'; import { useNostrBackupStore } from '@/stores/nostrBackup';
import { useNwcStore } from '@/stores/nwc';
// Wallet lifecycle bootstrap: reflects whatever is on this device into the // Wallet lifecycle bootstrap: reflects whatever is on this device into the
// wallet store at app start - a plaintext-stored key unlocks straight away, // 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 // instantiating the store arms its watchers: while the wallet is unlocked
// and nostr backup is enabled, store changes schedule debounced publishes // and nostr backup is enabled, store changes schedule debounced publishes
useNostrBackupStore(); 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();
}); });
+2
View File
@@ -20,6 +20,7 @@ const KIND_ICONS: Record<ActivityKind, string> = {
spent: 'check', spent: 'check',
deleted: 'delete', deleted: 'delete',
transfer: 'swap_horiz', transfer: 'swap_horiz',
nwc: 'bolt',
}; };
const KIND_COLORS: Record<ActivityKind, string> = { const KIND_COLORS: Record<ActivityKind, string> = {
@@ -31,6 +32,7 @@ const KIND_COLORS: Record<ActivityKind, string> = {
spent: 'grey-5', spent: 'grey-5',
deleted: 'negative', deleted: 'negative',
transfer: 'primary', transfer: 'primary',
nwc: 'primary',
}; };
const iconFor = (kind: ActivityKind): string => KIND_ICONS[kind]; const iconFor = (kind: ActivityKind): string => KIND_ICONS[kind];
+113
View File
@@ -0,0 +1,113 @@
<template>
<div>
<q-option-group
:model-value="preset"
dark
color="primary"
:options="presetOptions"
@update:model-value="pickPreset"
/>
<div v-if="preset === 'custom'" class="row q-gutter-sm q-mt-sm">
<q-input
v-model.number="customAmount"
dark
outlined
dense
color="primary"
type="number"
min="1"
label="Sats"
class="col"
@update:model-value="emitCustom"
/>
<q-select
v-model="customPeriod"
dark
outlined
dense
color="primary"
:options="periodOptions"
emit-value
map-options
label="Per"
class="col"
@update:model-value="emitCustom"
/>
</div>
</div>
</template>
<script setup lang="ts">
// Budget picker for NWC connections: preset sats-per-period choices plus a
// custom amount. The engine requires a concrete max (there is no
// "unlimited"), so the presets ARE the generosity ladder.
import { computed, ref } from 'vue';
import type { NwcBudget } from '@/lnurlcash/nwc';
import { satsToMsat } from '@/lnurlcash/units';
import { NWC_PERIOD_DAY_MS, NWC_PERIOD_WEEK_MS } from '@/stores/nwc';
const props = defineProps<{ modelValue: NwcBudget }>();
const emit = defineEmits<{ 'update:modelValue': [budget: NwcBudget] }>();
const PRESETS: { label: string; value: string; budget: NwcBudget }[] = [
{
label: '1,000 sats per day',
value: '1000:day',
budget: { maxMsat: satsToMsat(1_000), periodMs: NWC_PERIOD_DAY_MS },
},
{
label: '10,000 sats per day',
value: '10000:day',
budget: { maxMsat: satsToMsat(10_000), periodMs: NWC_PERIOD_DAY_MS },
},
{
label: '100,000 sats per day',
value: '100000:day',
budget: { maxMsat: satsToMsat(100_000), periodMs: NWC_PERIOD_DAY_MS },
},
{
label: '10,000 sats per week',
value: '10000:week',
budget: { maxMsat: satsToMsat(10_000), periodMs: NWC_PERIOD_WEEK_MS },
},
];
const presetOptions = [
...PRESETS.map(({ label, value }) => ({ label, value })),
{ label: 'Custom', value: 'custom' },
];
const periodOptions = [
{ label: 'day', value: NWC_PERIOD_DAY_MS },
{ label: 'week', value: NWC_PERIOD_WEEK_MS },
];
const matchingPreset = (budget: NwcBudget): string =>
PRESETS.find((p) => p.budget.maxMsat === budget.maxMsat && p.budget.periodMs === budget.periodMs)
?.value ?? 'custom';
const preset = computed(() => matchingPreset(props.modelValue));
const customAmount = ref(Math.round(props.modelValue.maxMsat / 1000) || 1_000);
const customPeriod = ref(
props.modelValue.periodMs === NWC_PERIOD_WEEK_MS ? NWC_PERIOD_WEEK_MS : NWC_PERIOD_DAY_MS,
);
const pickPreset = (value: string | number | null): void => {
const picked = PRESETS.find((p) => p.value === value);
if (picked) {
emit('update:modelValue', { ...picked.budget });
} else if (value === 'custom') {
emitCustom();
}
};
const emitCustom = (): void => {
const sats = Math.max(1, Math.floor(Number(customAmount.value) || 0));
emit('update:modelValue', {
maxMsat: satsToMsat(sats),
periodMs: customPeriod.value,
});
};
</script>
+39 -42
View File
@@ -2,9 +2,9 @@
// AES-GCM under the same bearer key, append-only, capped so a wallet used // AES-GCM under the same bearer key, append-only, capped so a wallet used
// for years doesn't grow localStorage without limit. // for years doesn't grow localStorage without limit.
import type {EncryptedRecordParts} from '../keys' import type { EncryptedRecordParts } from '../keys';
import {encryptRecord, decryptRecord} from '../keys' import { encryptRecord, decryptRecord } from '../keys';
import {withStorageLock} from '../storageLock' import { withStorageLock } from '../storageLock';
// `message` is the full human-readable sentence rather than structured // `message` is the full human-readable sentence rather than structured
// fields the UI reassembles, so the log stays simple to read and to extend // fields the UI reassembles, so the log stays simple to read and to extend
@@ -18,77 +18,74 @@ export type ActivityKind =
| 'receive' | 'receive'
| 'spent' | 'spent'
| 'deleted' | 'deleted'
// a payment or mint initiated by a Nostr Wallet Connect client (M5)
| 'nwc';
export type ActivityEvent = { export type ActivityEvent = {
id: string id: string;
kind: ActivityKind kind: ActivityKind;
message: string message: string;
createdAt: number 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 // bounds how far back the log ever reaches - the oldest entries simply
// roll off once this many are kept // roll off once this many are kept
export const MAX_ACTIVITY_ENTRIES = 500 export const MAX_ACTIVITY_ENTRIES = 500;
export const newActivityId = (): string => export const newActivityId = (): string =>
Array.from(crypto.getRandomValues(new Uint8Array(8))) Array.from(crypto.getRandomValues(new Uint8Array(8)))
.map(b => b.toString(16).padStart(2, '0')) .map((b) => b.toString(16).padStart(2, '0'))
.join('') .join('');
export const readEncryptedActivity = (): EncryptedActivityRecord[] => { export const readEncryptedActivity = (): EncryptedActivityRecord[] => {
const raw = localStorage.getItem(ACTIVITY_STORAGE_KEY) const raw = localStorage.getItem(ACTIVITY_STORAGE_KEY);
if (!raw) return [] if (!raw) return [];
try { try {
const parsed: unknown = JSON.parse(raw) const parsed: unknown = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [] return Array.isArray(parsed) ? parsed : [];
} catch { } catch {
return [] return [];
}
} }
};
const writeEncryptedActivity = (records: EncryptedActivityRecord[]): void => { 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 // same tolerance as loadBearers - an entry that fails to decrypt with this
// key (written by a different seed) is skipped, not destroyed // key (written by a different seed) is skipped, not destroyed
export const loadActivity = async ( export const loadActivity = async (aesKey: CryptoKey): Promise<ActivityEvent[]> => {
aesKey: CryptoKey const events: ActivityEvent[] = [];
): Promise<ActivityEvent[]> => {
const events: ActivityEvent[] = []
for (const record of readEncryptedActivity()) { for (const record of readEncryptedActivity()) {
try { try {
const event = await decryptRecord<Omit<ActivityEvent, 'id'>>( const event = await decryptRecord<Omit<ActivityEvent, 'id'>>(aesKey, record);
aesKey, events.push({ ...event, id: record.id });
record
)
events.push({...event, id: record.id})
} catch { } catch {
// undecryptable with this key - leave it in place // 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 // append-only (the log never edits or removes a single entry, only clears
// outright - see clearAllActivity) - records are stored oldest-first so // outright - see clearAllActivity) - records are stored oldest-first so
// trimming to the cap is just dropping off the front // trimming to the cap is just dropping off the front
export const persistActivityEvent = async ( export const persistActivityEvent = async (
aesKey: CryptoKey, aesKey: CryptoKey,
event: ActivityEvent event: ActivityEvent,
): Promise<void> => { ): Promise<void> => {
const {id, ...plain} = event const { id, ...plain } = event;
const parts = await encryptRecord(aesKey, plain) const parts = await encryptRecord(aesKey, plain);
await withStorageLock(ACTIVITY_STORAGE_KEY, () => { await withStorageLock(ACTIVITY_STORAGE_KEY, () => {
const records = readEncryptedActivity() const records = readEncryptedActivity();
records.push({id, ...parts}) records.push({ id, ...parts });
writeEncryptedActivity(records.slice(-MAX_ACTIVITY_ENTRIES)) writeEncryptedActivity(records.slice(-MAX_ACTIVITY_ENTRIES));
}) });
} };
export const clearAllActivity = (): void => { export const clearAllActivity = (): void => {
localStorage.removeItem(ACTIVITY_STORAGE_KEY) localStorage.removeItem(ACTIVITY_STORAGE_KEY);
} };
+334
View File
@@ -0,0 +1,334 @@
<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">Nostr Wallet Connect</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 - NWC connections need the wallet's key in memory.
</div>
</q-card>
<template v-else>
<!-- the honest explainer: foreground-only by design (see the nwc.ts
façade) - this wallet is not an always-on NWC service -->
<q-list class="sattle-card q-mb-md" bordered>
<q-item-label header class="text-primary text-weight-bold"> How this works </q-item-label>
<div class="q-pa-md q-pt-sm text-body2 text-grey-4">
Nostr Wallet Connect lets other apps (like Alby) pay and receive through this wallet over
public nostr relays. This wallet answers their requests
<strong>only while it is open and unlocked</strong> - requests sent while it is closed
wait on the relay and are dropped if they are more than ten minutes old when it next
opens, never executed late. Every connection has its own spending budget.
</div>
</q-list>
<!-- master switch + service state -->
<q-list class="sattle-card q-mb-md" bordered>
<q-item-label header class="text-primary text-weight-bold"> Service </q-item-label>
<div class="q-pa-md q-pt-sm">
<div class="row items-center justify-between">
<div class="text-body2 text-grey-4 col q-pr-md">
Answer requests from your connected apps.
</div>
<q-toggle
:model-value="nwc.enabled"
color="primary"
aria-label="Enable Nostr Wallet Connect"
@update:model-value="nwc.setEnabled"
/>
</div>
<div v-if="nwc.enabled" class="text-caption q-mt-sm" data-nwc-status>
<span v-if="nwc.running" class="text-positive">
Service running - answering requests for
{{ nwc.connections.length }} connection(s).
</span>
<span v-else class="text-grey-5">Starting the service…</span>
</div>
<div v-if="nwc.lastError" class="text-caption text-negative q-mt-sm">
{{ nwc.lastError }}
</div>
</div>
</q-list>
<!-- the one-time connection string, straight after creation - the
client secret inside it is never stored, so this is the only
chance to copy or scan it -->
<q-list v-if="createdString" class="sattle-card q-mb-md created-card" bordered>
<q-item-label header class="text-primary text-weight-bold">
Connection created
</q-item-label>
<div class="q-pa-md q-pt-sm">
<q-banner dense rounded class="sattle-card text-warning q-mb-md">
<template #avatar>
<q-icon name="warning" color="warning" />
</template>
This connection string is shown only once and cannot be recovered - copy it or scan it
into your app now. Anyone holding it can spend within the budget you set.
</q-banner>
<div class="row justify-center q-mb-md">
<QrCode :value="createdString" />
</div>
<div class="row items-center no-wrap q-mb-md">
<code class="nwc-connection-string text-grey-4 ellipsis">{{ createdString }}</code>
<q-btn
flat
dense
round
size="sm"
color="primary"
icon="content_copy"
aria-label="Copy connection string"
@click="copyConnectionString"
/>
</div>
<q-btn
unelevated
color="primary"
text-color="dark"
label="Done - I've saved it"
class="full-width"
@click="createdString = ''"
/>
</div>
</q-list>
<!-- connections -->
<q-list class="sattle-card q-mb-md" bordered separator>
<q-item-label header class="text-primary text-weight-bold"> Connections </q-item-label>
<q-item v-if="!nwc.connections.length">
<q-item-section class="text-grey-5">
No connections yet - create one below and paste its string into your app.
</q-item-section>
</q-item>
<q-item v-for="connection in nwc.connections" :key="connection.clientPubkey">
<q-item-section>
<q-item-label class="text-grey-3">
Client {{ fingerprint(connection.clientPubkey) }}
</q-item-label>
<q-item-label caption class="text-grey-5">
{{ connection.relays.join(', ') }}
</q-item-label>
<q-item-label caption class="text-grey-5">
{{ budgetLabel(connection) }} &middot; spent {{ spentLabel(connection) }} this period
</q-item-label>
<q-item-label caption class="text-grey-5">
Created {{ new Date(connection.createdAt).toLocaleDateString() }}
</q-item-label>
</q-item-section>
<q-item-section side>
<div class="column q-gutter-xs">
<q-btn
flat
dense
no-caps
color="primary"
label="Edit budget"
@click="askEditBudget(connection)"
/>
<q-btn
flat
dense
no-caps
color="negative"
label="Revoke"
@click="askRevoke(connection)"
/>
</div>
</q-item-section>
</q-item>
</q-list>
<!-- create -->
<q-list v-if="!createdString" class="sattle-card q-mb-md" bordered>
<q-item-label header class="text-primary text-weight-bold"> New connection </q-item-label>
<div class="q-pa-md q-pt-sm">
<div class="text-caption text-grey-5 q-mb-xs">Relays</div>
<RelaysEditor v-model="newRelays" />
<div class="text-caption text-grey-5 q-mb-xs q-mt-md">Spending budget</div>
<NwcBudgetPicker v-model="newBudget" />
<q-btn
unelevated
color="primary"
text-color="dark"
label="Create connection"
class="full-width q-mt-md"
:disable="newRelays.length === 0"
@click="createNewConnection"
/>
</div>
</q-list>
<q-banner v-if="banner" dense class="bg-negative text-white rounded-borders q-mb-md">
{{ banner }}
</q-banner>
</template>
<!-- edit budget -->
<q-dialog v-model="editingBudget">
<q-card class="sattle-card q-pa-lg">
<div class="text-h6 text-primary q-mb-sm">Edit budget</div>
<div class="text-body2 text-grey-4 q-mb-md">
New limit for client {{ fingerprint(editTarget?.clientPubkey ?? '') }}.
</div>
<NwcBudgetPicker v-model="editBudget" />
<div class="row q-gutter-sm justify-end q-mt-md">
<q-btn v-close-popup flat no-caps color="grey-5" label="Cancel" />
<q-btn
unelevated
no-caps
color="primary"
text-color="dark"
label="Save"
@click="saveBudget"
/>
</div>
</q-card>
</q-dialog>
<!-- revoke confirmation -->
<q-dialog v-model="confirmingRevoke">
<q-card class="sattle-card q-pa-lg">
<div class="text-h6 text-primary q-mb-sm">Revoke connection</div>
<div class="text-body2 text-grey-4 q-mb-md">
Revoke client {{ fingerprint(revokeTarget?.clientPubkey ?? '') }}? The service stops
answering it immediately, and its connection string stops working. The app can only
reconnect with a new connection.
</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="Revoke"
@click="doRevoke"
/>
</div>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { copyToClipboard, useQuasar } from 'quasar';
import type { NwcBudget, NwcConnectionRecord } from '@/lnurlcash/nwc';
import { msatToSats } from '@/lnurlcash/units';
import { useWalletStore } from '@/stores/wallet';
import { DEFAULT_NOSTR_RELAYS } from '@/stores/nostrBackup';
import { NWC_DEFAULT_BUDGET, NWC_PERIOD_WEEK_MS, useNwcStore } from '@/stores/nwc';
import RelaysEditor from '@/components/RelaysEditor.vue';
import QrCode from '@/components/QrCode.vue';
import NwcBudgetPicker from '@/components/NwcBudgetPicker.vue';
const router = useRouter();
const $q = useQuasar();
const wallet = useWalletStore();
const nwc = useNwcStore();
const toast = (type: 'positive' | 'negative', message: string): void => {
if (typeof $q.notify === 'function') {
$q.notify({ type, message, position: 'top', timeout: 3000 });
}
};
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 });
const budgetLabel = (connection: NwcConnectionRecord): string =>
`${formatSats(connection.budget.maxMsat)} sats per ${
connection.budget.periodMs === NWC_PERIOD_WEEK_MS ? 'week' : 'day'
}`;
// the period may already have rolled over - the engine treats an expired
// period as a full allowance again, so the display does too
const spentLabel = (connection: NwcConnectionRecord): string => {
const expired = Date.now() - connection.spent.periodStart >= connection.budget.periodMs;
return formatSats(expired ? 0 : connection.spent.msat);
};
// ---- create ----
const newRelays = ref<string[]>([...DEFAULT_NOSTR_RELAYS]);
const newBudget = ref<NwcBudget>({ ...NWC_DEFAULT_BUDGET });
// the one-time connection string - held only in this page's local state,
// cleared on "Done" and never re-rendered from any store
const createdString = ref('');
const banner = ref('');
const createNewConnection = (): void => {
banner.value = '';
try {
createdString.value = nwc.create(newRelays.value, newBudget.value).connectionString;
} catch (err) {
banner.value = err instanceof Error ? err.message : 'Could not create the connection.';
}
};
const copyConnectionString = (): void => {
void copyToClipboard(createdString.value).then(() =>
toast('positive', 'Connection string copied.'),
);
};
// ---- edit budget ----
const editingBudget = ref(false);
const editTarget = ref<NwcConnectionRecord | null>(null);
const editBudget = ref<NwcBudget>({ ...NWC_DEFAULT_BUDGET });
const askEditBudget = (connection: NwcConnectionRecord): void => {
editTarget.value = connection;
editBudget.value = { ...connection.budget };
editingBudget.value = true;
};
const saveBudget = (): void => {
editingBudget.value = false;
if (!editTarget.value) return;
nwc.updateBudget(editTarget.value.clientPubkey, editBudget.value);
toast('positive', 'Budget updated.');
};
// ---- revoke ----
const confirmingRevoke = ref(false);
const revokeTarget = ref<NwcConnectionRecord | null>(null);
const askRevoke = (connection: NwcConnectionRecord): void => {
revokeTarget.value = connection;
confirmingRevoke.value = true;
};
const doRevoke = (): void => {
confirmingRevoke.value = false;
if (!revokeTarget.value) return;
nwc.revoke(revokeTarget.value.clientPubkey);
toast('positive', 'Connection revoked.');
};
</script>
<style lang="scss" scoped>
.nwc-connection-string {
font-size: 0.75rem;
word-break: break-all;
}
.created-card {
border: 1px solid var(--q-primary);
}
</style>
+9 -3
View File
@@ -14,8 +14,8 @@
</div> </div>
<!-- group shells - entries are placeholders until their milestone <!-- group shells - entries are placeholders until their milestone
lands (see project plan: M2 flows, M4 backup/security, M5 NWC); lands (see project plan); Wallet (M4), Connections (M5) and the
the Mints group (M3) is live --> Mints group (M3) are live -->
<q-list <q-list
v-for="group in groups" v-for="group in groups"
:key="group.label" :key="group.label"
@@ -57,7 +57,13 @@ const groups: { label: string; items: SettingsItem[] }[] = [
{ label: 'Security', to: '/settings/security' }, { label: 'Security', to: '/settings/security' },
], ],
}, },
{ label: 'Connections', items: [{ label: 'Nostr Wallet Connect' }, { label: 'Nostr' }] }, {
label: 'Connections',
items: [
{ label: 'Nostr Wallet Connect', to: '/settings/nwc' },
{ label: 'Nostr backup & relays', to: '/settings/backup' },
],
},
{ {
label: 'Mints', label: 'Mints',
items: [ items: [
+4
View File
@@ -23,6 +23,10 @@ const routes: RouteRecordRaw[] = [
path: 'settings/move', path: 'settings/move',
component: () => import('@/pages/MoveFundsPage.vue'), component: () => import('@/pages/MoveFundsPage.vue'),
}, },
{
path: 'settings/nwc',
component: () => import('@/pages/NwcPage.vue'),
},
], ],
}, },
{ {
+222
View File
@@ -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<NwcConnectionRecord[]>(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<void> => {
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<string, unknown>).__sattleNwcTest = {
setTransport: setNwcTransportForTests,
};
}