mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
feat: nwc connections settings page with one-time connection strings and service lifecycle
This commit is contained in:
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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) }} · 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>
|
||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user