mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
feat: scope NWC storage to wallet owners
This commit is contained in:
@@ -0,0 +1,159 @@
|
|||||||
|
// The NWC wallet service end to end: connection strings and key
|
||||||
|
// derivation, the request/response cycle over an in-memory relay (the
|
||||||
|
// transport is injected - no network), every method against the
|
||||||
|
// conformance mock mint, the legacy NIP-04 path, budget enforcement, and
|
||||||
|
// the error paths. Fund-safety focus: budgets can't be exceeded, stale
|
||||||
|
// requests never execute, and a settled preimage only ever reveals an
|
||||||
|
// already-rotated (burned) note secret.
|
||||||
|
|
||||||
|
import {afterEach, beforeEach, describe, expect, it} from 'vitest'
|
||||||
|
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
|
||||||
|
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
|
||||||
|
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
|
||||||
|
import {v2 as nip44v2} from 'nostr-tools/nip44'
|
||||||
|
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit'
|
||||||
|
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
|
||||||
|
|
||||||
|
import {
|
||||||
|
NWC_INFO_KIND,
|
||||||
|
NWC_REQUEST_KIND,
|
||||||
|
NWC_RESPONSE_KIND,
|
||||||
|
buildConnectionString,
|
||||||
|
connectionInfoOf,
|
||||||
|
createConnection,
|
||||||
|
deriveNwcWalletKey,
|
||||||
|
migrateLegacyNwcStorage,
|
||||||
|
parseConnectionString,
|
||||||
|
readNwcEnabled,
|
||||||
|
readNwcConnections,
|
||||||
|
startService,
|
||||||
|
writeNwcEnabled,
|
||||||
|
writeNwcConnections,
|
||||||
|
} from './nwc'
|
||||||
|
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc'
|
||||||
|
import type {NostrFilter} from './nwc/transport'
|
||||||
|
import type {NwcChangeset} from './nwc'
|
||||||
|
import type {Bearer} from './types'
|
||||||
|
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys'
|
||||||
|
import {requiredValue, stubLocalStorage} from './test-utils'
|
||||||
|
|
||||||
|
import {
|
||||||
|
CLIENT_PUBKEY,
|
||||||
|
CLIENT_SECRET,
|
||||||
|
FAST_POLL,
|
||||||
|
LINKING_KEY,
|
||||||
|
OTHER_LINKING_KEY,
|
||||||
|
OTHER_OWNER_ID,
|
||||||
|
OWNER_ID,
|
||||||
|
RELAYS,
|
||||||
|
STRANGER_SECRET,
|
||||||
|
clientRequest,
|
||||||
|
createFakeRelay,
|
||||||
|
deferred,
|
||||||
|
foreignConnectionFixture,
|
||||||
|
methodRequest,
|
||||||
|
nowSeconds,
|
||||||
|
storeForeignConnection,
|
||||||
|
waitFor,
|
||||||
|
} from './nwc.testProtocol'
|
||||||
|
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
|
||||||
|
describe('service ownership', () => {
|
||||||
|
it('subscribes only current-owner records and leaves foreign budgets untouched', async () => {
|
||||||
|
const current = createConnection(LINKING_KEY, {
|
||||||
|
relays: RELAYS,
|
||||||
|
budget: {maxMsat: 1000, periodMs: 1000},
|
||||||
|
clientSecret: CLIENT_SECRET,
|
||||||
|
now: 0,
|
||||||
|
})
|
||||||
|
const foreign = foreignConnectionFixture({maxMsat: 2000, periodMs: 2000}, 0)
|
||||||
|
storeForeignConnection(foreign.record)
|
||||||
|
const relay = createFakeRelay()
|
||||||
|
|
||||||
|
const service = await startService(LINKING_KEY, {
|
||||||
|
assertCurrentOwner: () => undefined,
|
||||||
|
getBearers: () => [],
|
||||||
|
getDefaultMint: () => null,
|
||||||
|
applyChangeset: () => Promise.resolve(),
|
||||||
|
transport: relay.transport,
|
||||||
|
nowSeconds,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(service.connections.map((connection) => connection.record)).toEqual([current.record])
|
||||||
|
expect(relay.subscriptionCount()).toBe(1)
|
||||||
|
expect(readNwcConnections(OTHER_OWNER_ID)[0]?.spent.msat).toBe(0)
|
||||||
|
expect(service.connections).not.toContainEqual(foreign)
|
||||||
|
await service.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores foreign records handed in through the records snapshot', async () => {
|
||||||
|
// the injected-records path bypasses storage, so the service's own
|
||||||
|
// owner filter is the only boundary here (stale-ownership probe: a
|
||||||
|
// snapshot from a previous wallet must not be served)
|
||||||
|
const current = createConnection(LINKING_KEY, {
|
||||||
|
relays: RELAYS,
|
||||||
|
budget: {maxMsat: 1000, periodMs: 1000},
|
||||||
|
clientSecret: CLIENT_SECRET,
|
||||||
|
now: 0,
|
||||||
|
})
|
||||||
|
const foreign = foreignConnectionFixture({maxMsat: 2000, periodMs: 2000}, 0)
|
||||||
|
const relay = createFakeRelay()
|
||||||
|
|
||||||
|
const service = await startService(
|
||||||
|
LINKING_KEY,
|
||||||
|
{
|
||||||
|
assertCurrentOwner: () => undefined,
|
||||||
|
getBearers: () => [],
|
||||||
|
getDefaultMint: () => null,
|
||||||
|
applyChangeset: () => Promise.resolve(),
|
||||||
|
transport: relay.transport,
|
||||||
|
nowSeconds,
|
||||||
|
},
|
||||||
|
[current.record, foreign.record],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(service.connections.map((connection) => connection.record)).toEqual([current.record])
|
||||||
|
expect(relay.subscriptionCount()).toBe(1)
|
||||||
|
// the foreign snapshot record must not be persisted for the new owner
|
||||||
|
expect(readNwcConnections(OWNER_ID)).toEqual([current.record])
|
||||||
|
await service.stop()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('service: info and balance', () => {
|
||||||
|
it('publishes a kind-13194 info event on startup', async () => {
|
||||||
|
const {relay, walletServicePubkey, stop} = await startTestService({})
|
||||||
|
const info = requiredValue(relay.published.find((e) => e.kind === NWC_INFO_KIND))
|
||||||
|
expect(info.pubkey).toBe(walletServicePubkey)
|
||||||
|
expect(info.content).toContain('pay_invoice')
|
||||||
|
expect(info.content).toContain('make_invoice')
|
||||||
|
expect(info.tags).toContainEqual(['encryption', 'nip44_v2 nip04'])
|
||||||
|
await stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('answers get_info with the connection identity and method list', async () => {
|
||||||
|
const {relay, walletServicePubkey, stop} = await startTestService({})
|
||||||
|
const response = await call(relay, walletServicePubkey, 'get_info', {})
|
||||||
|
expect(response.error).toBeNull()
|
||||||
|
expect(response.result_type).toBe('get_info')
|
||||||
|
expect(response.result).toMatchObject({
|
||||||
|
alias: 'sattle',
|
||||||
|
pubkey: walletServicePubkey,
|
||||||
|
methods: ['get_info', 'get_balance', 'make_invoice', 'pay_invoice', 'lookup_invoice'],
|
||||||
|
})
|
||||||
|
await stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('answers get_balance with the spendable total only', async () => {
|
||||||
|
const m = await mint()
|
||||||
|
const {relay, walletServicePubkey, state, stop} = await startTestService({})
|
||||||
|
state.bearers = [
|
||||||
|
await makeBearer(m, 'aa'.repeat(32), 21_000),
|
||||||
|
await makeBearer(m, 'bb'.repeat(32), 5_000),
|
||||||
|
{...(await makeBearer(m, 'cc'.repeat(32), 99_000)), spent: true},
|
||||||
|
]
|
||||||
|
const response = await call(relay, walletServicePubkey, 'get_balance', {})
|
||||||
|
expect(response.error).toBeNull()
|
||||||
|
expect(response.result).toEqual({balance: 26_000})
|
||||||
|
await stop()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// The NWC wallet service end to end: connection strings and key
|
||||||
|
// derivation, the request/response cycle over an in-memory relay (the
|
||||||
|
// transport is injected - no network), every method against the
|
||||||
|
// conformance mock mint, the legacy NIP-04 path, budget enforcement, and
|
||||||
|
// the error paths. Fund-safety focus: budgets can't be exceeded, stale
|
||||||
|
// requests never execute, and a settled preimage only ever reveals an
|
||||||
|
// already-rotated (burned) note secret.
|
||||||
|
|
||||||
|
import {afterEach, beforeEach, describe, expect, it} from 'vitest'
|
||||||
|
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
|
||||||
|
import {finalizeEvent, getPublicKey} from 'nostr-tools/pure'
|
||||||
|
import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04'
|
||||||
|
import {v2 as nip44v2} from 'nostr-tools/nip44'
|
||||||
|
import {buildNoteUrl, fetchNoteInfo, noteK1} from 'lnurlcash-kit'
|
||||||
|
import {createMockMint} from 'lnurlcash-conformance/mock-mint'
|
||||||
|
|
||||||
|
import {
|
||||||
|
NWC_INFO_KIND,
|
||||||
|
NWC_REQUEST_KIND,
|
||||||
|
NWC_RESPONSE_KIND,
|
||||||
|
buildConnectionString,
|
||||||
|
connectionInfoOf,
|
||||||
|
createConnection,
|
||||||
|
deriveNwcWalletKey,
|
||||||
|
migrateLegacyNwcStorage,
|
||||||
|
parseConnectionString,
|
||||||
|
readNwcEnabled,
|
||||||
|
readNwcConnections,
|
||||||
|
startService,
|
||||||
|
writeNwcEnabled,
|
||||||
|
writeNwcConnections,
|
||||||
|
} from './nwc'
|
||||||
|
import type {NostrEvent, NwcConnectionRecord, NwcServiceDeps, NwcTransport} from './nwc'
|
||||||
|
import type {NostrFilter} from './nwc/transport'
|
||||||
|
import type {NwcChangeset} from './nwc'
|
||||||
|
import type {Bearer} from './types'
|
||||||
|
import {ensureSavedKeyOwner, linkingPubKeyHex, saveLinkingKey} from './keys'
|
||||||
|
import {requiredValue, stubLocalStorage} from './test-utils'
|
||||||
|
|
||||||
|
import {
|
||||||
|
CLIENT_PUBKEY,
|
||||||
|
CLIENT_SECRET,
|
||||||
|
FAST_POLL,
|
||||||
|
LINKING_KEY,
|
||||||
|
OTHER_LINKING_KEY,
|
||||||
|
OTHER_OWNER_ID,
|
||||||
|
OWNER_ID,
|
||||||
|
RELAYS,
|
||||||
|
STRANGER_SECRET,
|
||||||
|
clientRequest,
|
||||||
|
createFakeRelay,
|
||||||
|
deferred,
|
||||||
|
foreignConnectionFixture,
|
||||||
|
methodRequest,
|
||||||
|
nowSeconds,
|
||||||
|
storeForeignConnection,
|
||||||
|
waitFor,
|
||||||
|
} from './nwc.testProtocol'
|
||||||
|
import {call, makeBearer, mint, readResponse, startTestService} from './nwc.testService'
|
||||||
|
describe('storage validation', () => {
|
||||||
|
it('drops malformed records instead of throwing', () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
'sattle_nwc_connections',
|
||||||
|
JSON.stringify([
|
||||||
|
{clientPubkey: 'nope'},
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
ownerId: OWNER_ID,
|
||||||
|
clientPubkey: CLIENT_PUBKEY,
|
||||||
|
relays: RELAYS,
|
||||||
|
budget: {maxMsat: 1000, periodMs: 1000},
|
||||||
|
spent: {periodStart: 0, msat: 0},
|
||||||
|
createdAt: 0,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
expect(readNwcConnections(OWNER_ID)).toHaveLength(1)
|
||||||
|
expect(requiredValue(readNwcConnections(OWNER_ID)[0]).clientPubkey).toBe(CLIENT_PUBKEY)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns nothing for garbage json', () => {
|
||||||
|
localStorage.setItem('sattle_nwc_connections', '{{{')
|
||||||
|
expect(readNwcConnections(OWNER_ID)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns only strictly parsed records belonging to the requested owner', () => {
|
||||||
|
const current = createConnection(LINKING_KEY, {
|
||||||
|
relays: RELAYS,
|
||||||
|
budget: {maxMsat: 1000, periodMs: 1000},
|
||||||
|
clientSecret: CLIENT_SECRET,
|
||||||
|
now: 10,
|
||||||
|
}).record
|
||||||
|
const foreign = foreignConnectionFixture({maxMsat: 2000, periodMs: 2000}, 20).record
|
||||||
|
const raw: unknown = JSON.parse(localStorage.getItem('sattle_nwc_connections') ?? '[]')
|
||||||
|
if (!Array.isArray(raw)) throw new TypeError('Expected stored NWC records')
|
||||||
|
localStorage.setItem(
|
||||||
|
'sattle_nwc_connections',
|
||||||
|
JSON.stringify([
|
||||||
|
...raw,
|
||||||
|
foreign,
|
||||||
|
{...current, ownerId: 'malformed'},
|
||||||
|
{
|
||||||
|
clientPubkey: getPublicKey(hexToBytes('33'.repeat(32))),
|
||||||
|
relays: RELAYS,
|
||||||
|
budget: {maxMsat: 3000, periodMs: 3000},
|
||||||
|
spent: {periodStart: 0, msat: 0},
|
||||||
|
createdAt: 30,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(readNwcConnections(OWNER_ID)).toEqual([current])
|
||||||
|
expect(readNwcConnections(OTHER_OWNER_ID)).toEqual([foreign])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adopts ownerless connections and enabled state only after owner proof', async () => {
|
||||||
|
await saveLinkingKey(LINKING_KEY)
|
||||||
|
const saved: unknown = JSON.parse(localStorage.getItem('sattle_linking_key') ?? '{}')
|
||||||
|
if (typeof saved !== 'object' || saved === null) {
|
||||||
|
throw new TypeError('Expected a saved linking-key record')
|
||||||
|
}
|
||||||
|
Reflect.deleteProperty(saved, 'ownerId')
|
||||||
|
Reflect.deleteProperty(saved, 'version')
|
||||||
|
localStorage.setItem('sattle_linking_key', JSON.stringify(saved))
|
||||||
|
localStorage.setItem(
|
||||||
|
'sattle_nwc_connections',
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
clientPubkey: CLIENT_PUBKEY,
|
||||||
|
relays: RELAYS,
|
||||||
|
budget: {maxMsat: 1000, periodMs: 1000},
|
||||||
|
spent: {periodStart: 0, msat: 0},
|
||||||
|
createdAt: 0,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
localStorage.setItem('sattle_nwc_enabled', 'true')
|
||||||
|
|
||||||
|
expect(() => migrateLegacyNwcStorage(LINKING_KEY)).toThrow()
|
||||||
|
expect(readNwcConnections(OWNER_ID)).toEqual([])
|
||||||
|
expect(readNwcEnabled(OWNER_ID)).toBe(false)
|
||||||
|
|
||||||
|
ensureSavedKeyOwner(LINKING_KEY)
|
||||||
|
expect(migrateLegacyNwcStorage(LINKING_KEY)).toEqual({
|
||||||
|
connections: 1,
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
expect(readNwcConnections(OWNER_ID)).toHaveLength(1)
|
||||||
|
expect(readNwcEnabled(OWNER_ID)).toBe(true)
|
||||||
|
expect(migrateLegacyNwcStorage(LINKING_KEY)).toEqual({
|
||||||
|
connections: 0,
|
||||||
|
enabled: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not expose one wallet enabled state to another owner', () => {
|
||||||
|
writeNwcEnabled(OWNER_ID, true)
|
||||||
|
|
||||||
|
expect(readNwcEnabled(OWNER_ID)).toBe(true)
|
||||||
|
expect(readNwcEnabled(OTHER_OWNER_ID)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats malformed owner-bearing enabled records as disabled', () => {
|
||||||
|
for (const value of [
|
||||||
|
{version: 1, ownerId: 'malformed', enabled: true},
|
||||||
|
{version: 2, ownerId: OWNER_ID, enabled: true},
|
||||||
|
{version: 1, ownerId: OWNER_ID, enabled: 'true'},
|
||||||
|
]) {
|
||||||
|
localStorage.setItem('sattle_nwc_enabled', JSON.stringify(value))
|
||||||
|
expect(readNwcEnabled(OWNER_ID)).toBe(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,28 +1,34 @@
|
|||||||
// NWC connection persistence: one localStorage record holding every NIP-47
|
// NWC connection persistence. Each record and the service-enabled setting
|
||||||
// connection this wallet serves (see nwc.ts). A record is public metadata
|
// belong to one canonical wallet owner, so local residue from another wallet
|
||||||
// only - the wallet-service key is re-derived from the linking key and the
|
// is never served, edited, revoked, or charged. Hostile localStorage input is
|
||||||
// client pubkey (nwc/connection.ts's deriveNwcWalletKey), and the CLIENT
|
// parsed before use; ownerless v0 records remain hidden until an already
|
||||||
// secret is never stored at all (NIP-47: the wallet service should not
|
// proven saved wallet explicitly migrates them.
|
||||||
// store the secret it generates for the client). The budget spend counter
|
|
||||||
// lives here so a restart doesn't reset a client's allowance.
|
import {linkingPubKeyHex, savedKeyOwnerId} from '../keys'
|
||||||
|
import {
|
||||||
|
clearNwcEnabledForOwner,
|
||||||
|
clearUnownedNwcEnabled,
|
||||||
|
readLegacyNwcEnabled,
|
||||||
|
writeNwcEnabled,
|
||||||
|
} from './nwcEnabled'
|
||||||
|
import {savedKeyOwnerAllows} from './currentOwner'
|
||||||
|
import {isWalletOwnerId} from './walletOwner'
|
||||||
|
|
||||||
export type NwcBudget = {
|
export type NwcBudget = {
|
||||||
// the most this connection may pay per period, msat
|
|
||||||
maxMsat: number
|
maxMsat: number
|
||||||
// the period length in milliseconds (e.g. 86_400_000 for daily)
|
|
||||||
periodMs: number
|
periodMs: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// spend within the current period; rolls over once periodStart is more
|
|
||||||
// than budget.periodMs in the past
|
|
||||||
export type NwcBudgetSpend = {
|
export type NwcBudgetSpend = {
|
||||||
periodStart: number
|
periodStart: number
|
||||||
msat: number
|
msat: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NWC_RECORD_VERSION = 1
|
||||||
|
|
||||||
export type NwcConnectionRecord = {
|
export type NwcConnectionRecord = {
|
||||||
// the authorized client's pubkey (the pubkey of the client secret that
|
version: typeof NWC_RECORD_VERSION
|
||||||
// was handed out in the connection string, once, at creation time)
|
ownerId: string
|
||||||
clientPubkey: string
|
clientPubkey: string
|
||||||
relays: string[]
|
relays: string[]
|
||||||
budget: NwcBudget
|
budget: NwcBudget
|
||||||
@@ -30,80 +36,192 @@ export type NwcConnectionRecord = {
|
|||||||
createdAt: number
|
createdAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LegacyNwcConnectionRecord = Omit<NwcConnectionRecord, 'version' | 'ownerId'>
|
||||||
|
|
||||||
|
type StoredNwcConnection =
|
||||||
|
{kind: 'owned'; record: NwcConnectionRecord} | {kind: 'legacy'; record: LegacyNwcConnectionRecord}
|
||||||
|
|
||||||
const NWC_CONNECTIONS_STORAGE_KEY = 'sattle_nwc_connections'
|
const NWC_CONNECTIONS_STORAGE_KEY = 'sattle_nwc_connections'
|
||||||
|
|
||||||
const HEX_64 = /^[0-9a-f]{64}$/i
|
const HEX_64 = /^[0-9a-f]{64}$/
|
||||||
|
|
||||||
// strict shape check, same spirit as passkeySlots.ts: localStorage content
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
// is not trustworthy input, so records are validated before use
|
typeof value === 'object' && value !== null
|
||||||
const isValidNwcConnectionRecord = (
|
|
||||||
record: unknown
|
const isPositiveInteger = (value: unknown): value is number =>
|
||||||
): record is NwcConnectionRecord => {
|
typeof value === 'number' && Number.isSafeInteger(value) && value > 0
|
||||||
if (typeof record !== 'object' || record === null) return false
|
|
||||||
const r = record as Record<string, unknown>
|
const isNonNegativeInteger = (value: unknown): value is number =>
|
||||||
const budget = r.budget as Record<string, unknown> | null
|
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
|
||||||
const spent = r.spent as Record<string, unknown> | null
|
|
||||||
return (
|
const isRelay = (value: unknown): value is string => {
|
||||||
typeof r.clientPubkey === 'string' &&
|
if (typeof value !== 'string') return false
|
||||||
HEX_64.test(r.clientPubkey) &&
|
try {
|
||||||
Array.isArray(r.relays) &&
|
const url = new URL(value)
|
||||||
r.relays.length > 0 &&
|
return url.protocol === 'wss:' || url.protocol === 'ws:'
|
||||||
r.relays.every(
|
} catch {
|
||||||
relay => typeof relay === 'string' && /^wss?:\/\//.test(relay)
|
return false
|
||||||
) &&
|
}
|
||||||
typeof budget === 'object' &&
|
|
||||||
budget !== null &&
|
|
||||||
typeof budget.maxMsat === 'number' &&
|
|
||||||
Number.isInteger(budget.maxMsat) &&
|
|
||||||
budget.maxMsat > 0 &&
|
|
||||||
typeof budget.periodMs === 'number' &&
|
|
||||||
budget.periodMs > 0 &&
|
|
||||||
typeof spent === 'object' &&
|
|
||||||
spent !== null &&
|
|
||||||
typeof spent.periodStart === 'number' &&
|
|
||||||
typeof spent.msat === 'number' &&
|
|
||||||
spent.msat >= 0 &&
|
|
||||||
typeof r.createdAt === 'number'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// malformed entries are dropped, not thrown on - one corrupted record must
|
const parseStoredConnection = (value: unknown): StoredNwcConnection | null => {
|
||||||
// not take the remaining connections down with it
|
if (!isRecord(value)) return null
|
||||||
export const readNwcConnections = (): NwcConnectionRecord[] => {
|
const {clientPubkey, relays, budget, spent, createdAt} = value
|
||||||
|
if (
|
||||||
|
typeof clientPubkey !== 'string' ||
|
||||||
|
!HEX_64.test(clientPubkey) ||
|
||||||
|
!Array.isArray(relays) ||
|
||||||
|
relays.length === 0 ||
|
||||||
|
!relays.every(isRelay) ||
|
||||||
|
!isRecord(budget) ||
|
||||||
|
!isPositiveInteger(budget.maxMsat) ||
|
||||||
|
!isPositiveInteger(budget.periodMs) ||
|
||||||
|
!isRecord(spent) ||
|
||||||
|
!isNonNegativeInteger(spent.periodStart) ||
|
||||||
|
!isNonNegativeInteger(spent.msat) ||
|
||||||
|
!isNonNegativeInteger(createdAt)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const base: LegacyNwcConnectionRecord = {
|
||||||
|
clientPubkey,
|
||||||
|
relays,
|
||||||
|
budget: {maxMsat: budget.maxMsat, periodMs: budget.periodMs},
|
||||||
|
spent: {periodStart: spent.periodStart, msat: spent.msat},
|
||||||
|
createdAt,
|
||||||
|
}
|
||||||
|
if (!Object.hasOwn(value, 'version') && !Object.hasOwn(value, 'ownerId')) {
|
||||||
|
return {kind: 'legacy', record: base}
|
||||||
|
}
|
||||||
|
if (value.version !== NWC_RECORD_VERSION || !isWalletOwnerId(value.ownerId)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: 'owned',
|
||||||
|
record: {...base, version: NWC_RECORD_VERSION, ownerId: value.ownerId},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const readStoredConnections = (): StoredNwcConnection[] => {
|
||||||
const raw = localStorage.getItem(NWC_CONNECTIONS_STORAGE_KEY)
|
const raw = localStorage.getItem(NWC_CONNECTIONS_STORAGE_KEY)
|
||||||
if (!raw) return []
|
if (raw === null) return []
|
||||||
try {
|
try {
|
||||||
const parsed: unknown = JSON.parse(raw)
|
const parsed: unknown = JSON.parse(raw)
|
||||||
return Array.isArray(parsed)
|
if (!Array.isArray(parsed)) return []
|
||||||
? parsed.filter(isValidNwcConnectionRecord)
|
return parsed
|
||||||
: []
|
.map(parseStoredConnection)
|
||||||
|
.filter((entry): entry is StoredNwcConnection => entry !== null)
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const writeNwcConnections = (
|
const storedValue = (entry: StoredNwcConnection): NwcConnectionRecord | LegacyNwcConnectionRecord =>
|
||||||
records: NwcConnectionRecord[]
|
entry.record
|
||||||
): void => {
|
|
||||||
localStorage.setItem(NWC_CONNECTIONS_STORAGE_KEY, JSON.stringify(records))
|
export const readNwcConnections = (ownerId: unknown): NwcConnectionRecord[] => {
|
||||||
|
if (!isWalletOwnerId(ownerId)) return []
|
||||||
|
return readStoredConnections()
|
||||||
|
.filter(
|
||||||
|
(entry): entry is Extract<StoredNwcConnection, {kind: 'owned'}> =>
|
||||||
|
entry.kind === 'owned' && entry.record.ownerId === ownerId,
|
||||||
|
)
|
||||||
|
.map((entry) => entry.record)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const writeNwcConnections = (ownerId: unknown, records: NwcConnectionRecord[]): void => {
|
||||||
|
if (!isWalletOwnerId(ownerId) || !savedKeyOwnerAllows(ownerId)) {
|
||||||
|
throw new Error('NWC connections require a valid wallet owner.')
|
||||||
|
}
|
||||||
|
const canonical: NwcConnectionRecord[] = []
|
||||||
|
for (const record of records) {
|
||||||
|
const parsed = parseStoredConnection(record)
|
||||||
|
if (parsed?.kind !== 'owned' || parsed.record.ownerId !== ownerId) {
|
||||||
|
throw new Error('Refusing to write an invalid or foreign NWC connection.')
|
||||||
|
}
|
||||||
|
canonical.push(parsed.record)
|
||||||
|
}
|
||||||
|
const preserved = readStoredConnections()
|
||||||
|
.filter((entry) => entry.kind === 'legacy' || entry.record.ownerId !== ownerId)
|
||||||
|
.map(storedValue)
|
||||||
|
localStorage.setItem(NWC_CONNECTIONS_STORAGE_KEY, JSON.stringify([...preserved, ...canonical]))
|
||||||
}
|
}
|
||||||
|
|
||||||
// upsert by client pubkey; returns the stored record. Callers serialize
|
|
||||||
// read-modify-write cycles themselves (the NWC service serializes per
|
|
||||||
// connection through its request queue)
|
|
||||||
export const persistNwcConnection = (
|
export const persistNwcConnection = (
|
||||||
record: NwcConnectionRecord
|
ownerId: unknown,
|
||||||
|
record: NwcConnectionRecord,
|
||||||
): NwcConnectionRecord => {
|
): NwcConnectionRecord => {
|
||||||
const records = readNwcConnections()
|
if (!isWalletOwnerId(ownerId) || record.ownerId !== ownerId) {
|
||||||
const index = records.findIndex(r => r.clientPubkey === record.clientPubkey)
|
throw new Error('NWC connection writes require a valid wallet owner.')
|
||||||
|
}
|
||||||
|
const records = readNwcConnections(ownerId)
|
||||||
|
const index = records.findIndex((stored) => stored.clientPubkey === record.clientPubkey)
|
||||||
if (index >= 0) records[index] = record
|
if (index >= 0) records[index] = record
|
||||||
else records.push(record)
|
else records.push(record)
|
||||||
writeNwcConnections(records)
|
writeNwcConnections(ownerId, records)
|
||||||
return record
|
return record
|
||||||
}
|
}
|
||||||
|
|
||||||
export const removeNwcConnection = (clientPubkey: string): void => {
|
export const removeNwcConnection = (ownerId: unknown, clientPubkey: string): void => {
|
||||||
|
if (!isWalletOwnerId(ownerId)) return
|
||||||
writeNwcConnections(
|
writeNwcConnections(
|
||||||
readNwcConnections().filter(r => r.clientPubkey !== clientPubkey)
|
ownerId,
|
||||||
|
readNwcConnections(ownerId).filter((record) => record.clientPubkey !== clientPubkey),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type NwcLegacyMigrationResult = {
|
||||||
|
connections: number
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const migrateLegacyNwcStorage = (linkingKey: Uint8Array): NwcLegacyMigrationResult => {
|
||||||
|
const ownerId = linkingPubKeyHex(linkingKey)
|
||||||
|
if (savedKeyOwnerId() !== ownerId) {
|
||||||
|
throw new Error('Legacy NWC migration requires a proven saved wallet owner.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = readStoredConnections()
|
||||||
|
let connections = 0
|
||||||
|
const migrated = stored.map((entry) => {
|
||||||
|
if (entry.kind === 'owned') return entry.record
|
||||||
|
connections += 1
|
||||||
|
return {
|
||||||
|
...entry.record,
|
||||||
|
version: NWC_RECORD_VERSION,
|
||||||
|
ownerId,
|
||||||
|
} satisfies NwcConnectionRecord
|
||||||
|
})
|
||||||
|
if (connections > 0) {
|
||||||
|
localStorage.setItem(NWC_CONNECTIONS_STORAGE_KEY, JSON.stringify(migrated))
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyEnabled = readLegacyNwcEnabled()
|
||||||
|
if (legacyEnabled !== null) writeNwcEnabled(ownerId, legacyEnabled)
|
||||||
|
return {connections, enabled: legacyEnabled !== null}
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistStoredConnections = (entries: StoredNwcConnection[]): void => {
|
||||||
|
if (entries.length === 0) {
|
||||||
|
localStorage.removeItem(NWC_CONNECTIONS_STORAGE_KEY)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
localStorage.setItem(NWC_CONNECTIONS_STORAGE_KEY, JSON.stringify(entries.map(storedValue)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clearNwcStorageForOwner = (ownerId: unknown): void => {
|
||||||
|
if (!isWalletOwnerId(ownerId) || !savedKeyOwnerAllows(ownerId)) {
|
||||||
|
throw new Error('NWC teardown requires a valid wallet owner.')
|
||||||
|
}
|
||||||
|
persistStoredConnections(
|
||||||
|
readStoredConnections().filter(
|
||||||
|
(entry) => entry.kind === 'legacy' || entry.record.ownerId !== ownerId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
clearNwcEnabledForOwner(ownerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clearUnownedNwcStorage = (): void => {
|
||||||
|
persistStoredConnections(readStoredConnections().filter((entry) => entry.kind === 'owned'))
|
||||||
|
clearUnownedNwcEnabled()
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// NWC service-enabled persistence: one owner-bearing record for whether the
|
||||||
|
// wallet service should run. Split from nwcConnections.ts (size ceiling) -
|
||||||
|
// the enabled flag and the connection records are independent storage keys
|
||||||
|
// with the same ownership rules: hostile input is parsed before use, and a
|
||||||
|
// legacy global 'true'/'false' string counts as ownerless residue until an
|
||||||
|
// already proven saved wallet migrates it.
|
||||||
|
|
||||||
|
import {isWalletOwnerId} from './walletOwner'
|
||||||
|
import {savedKeyOwnerAllows} from './currentOwner'
|
||||||
|
|
||||||
|
const NWC_ENABLED_STORAGE_KEY = 'sattle_nwc_enabled'
|
||||||
|
const NWC_ENABLED_VERSION = 1
|
||||||
|
|
||||||
|
type NwcEnabledRecord = {
|
||||||
|
version: typeof NWC_ENABLED_VERSION
|
||||||
|
ownerId: string
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
typeof value === 'object' && value !== null
|
||||||
|
|
||||||
|
const readNwcEnabledRecord = (): NwcEnabledRecord | null => {
|
||||||
|
const raw = localStorage.getItem(NWC_ENABLED_STORAGE_KEY)
|
||||||
|
if (raw === null) return null
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(raw)
|
||||||
|
if (
|
||||||
|
!isRecord(parsed) ||
|
||||||
|
parsed.version !== NWC_ENABLED_VERSION ||
|
||||||
|
!isWalletOwnerId(parsed.ownerId) ||
|
||||||
|
typeof parsed.enabled !== 'boolean'
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: NWC_ENABLED_VERSION,
|
||||||
|
ownerId: parsed.ownerId,
|
||||||
|
enabled: parsed.enabled,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const readNwcEnabled = (ownerId: unknown): boolean => {
|
||||||
|
if (!isWalletOwnerId(ownerId)) return false
|
||||||
|
const record = readNwcEnabledRecord()
|
||||||
|
return record?.ownerId === ownerId && record.enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
export const writeNwcEnabled = (ownerId: unknown, enabled: boolean): void => {
|
||||||
|
if (!isWalletOwnerId(ownerId) || !savedKeyOwnerAllows(ownerId)) {
|
||||||
|
throw new Error('NWC enabled state requires a valid wallet owner.')
|
||||||
|
}
|
||||||
|
localStorage.setItem(
|
||||||
|
NWC_ENABLED_STORAGE_KEY,
|
||||||
|
JSON.stringify({version: NWC_ENABLED_VERSION, ownerId, enabled}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the pre-owner storage form was a bare 'true'/'false' string - returns it
|
||||||
|
// when present so legacy migration can re-home the value under the proven
|
||||||
|
// owner, null for anything else (absent, junk, or an owned envelope)
|
||||||
|
export const readLegacyNwcEnabled = (): boolean | null => {
|
||||||
|
const raw = localStorage.getItem(NWC_ENABLED_STORAGE_KEY)
|
||||||
|
if (raw === 'true') return true
|
||||||
|
if (raw === 'false') return false
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// teardown of one owner's enabled state - any other owner's record (or a
|
||||||
|
// legacy string) is left exactly as found
|
||||||
|
export const clearNwcEnabledForOwner = (ownerId: unknown): void => {
|
||||||
|
if (!isWalletOwnerId(ownerId)) return
|
||||||
|
if (readNwcEnabledRecord()?.ownerId === ownerId) {
|
||||||
|
localStorage.removeItem(NWC_ENABLED_STORAGE_KEY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// install-time residue sweep: only the legacy string form is unowned; an
|
||||||
|
// owned envelope stays (it is inert for every other owner)
|
||||||
|
export const clearUnownedNwcEnabled = (): void => {
|
||||||
|
if (readLegacyNwcEnabled() !== null) {
|
||||||
|
localStorage.removeItem(NWC_ENABLED_STORAGE_KEY)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user