feat: scope NWC storage to wallet owners

This commit is contained in:
2026-08-22 16:55:23 +02:00
parent 3054834cdb
commit 9f6bcad706
4 changed files with 602 additions and 65 deletions
+159
View File
@@ -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()
})
})
+173
View File
@@ -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)
}
})
})
+183 -65
View File
@@ -1,28 +1,34 @@
// NWC connection persistence: one localStorage record holding every NIP-47
// connection this wallet serves (see nwc.ts). A record is public metadata
// only - the wallet-service key is re-derived from the linking key and the
// client pubkey (nwc/connection.ts's deriveNwcWalletKey), and the CLIENT
// secret is never stored at all (NIP-47: the wallet service should not
// store the secret it generates for the client). The budget spend counter
// lives here so a restart doesn't reset a client's allowance.
// NWC connection persistence. Each record and the service-enabled setting
// belong to one canonical wallet owner, so local residue from another wallet
// is never served, edited, revoked, or charged. Hostile localStorage input is
// parsed before use; ownerless v0 records remain hidden until an already
// proven saved wallet explicitly migrates them.
import {linkingPubKeyHex, savedKeyOwnerId} from '../keys'
import {
clearNwcEnabledForOwner,
clearUnownedNwcEnabled,
readLegacyNwcEnabled,
writeNwcEnabled,
} from './nwcEnabled'
import {savedKeyOwnerAllows} from './currentOwner'
import {isWalletOwnerId} from './walletOwner'
export type NwcBudget = {
// the most this connection may pay per period, msat
maxMsat: number
// the period length in milliseconds (e.g. 86_400_000 for daily)
periodMs: number
}
// spend within the current period; rolls over once periodStart is more
// than budget.periodMs in the past
export type NwcBudgetSpend = {
periodStart: number
msat: number
}
const NWC_RECORD_VERSION = 1
export type NwcConnectionRecord = {
// the authorized client's pubkey (the pubkey of the client secret that
// was handed out in the connection string, once, at creation time)
version: typeof NWC_RECORD_VERSION
ownerId: string
clientPubkey: string
relays: string[]
budget: NwcBudget
@@ -30,80 +36,192 @@ export type NwcConnectionRecord = {
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 HEX_64 = /^[0-9a-f]{64}$/i
const HEX_64 = /^[0-9a-f]{64}$/
// strict shape check, same spirit as passkeySlots.ts: localStorage content
// is not trustworthy input, so records are validated before use
const isValidNwcConnectionRecord = (
record: unknown
): record is NwcConnectionRecord => {
if (typeof record !== 'object' || record === null) return false
const r = record as Record<string, unknown>
const budget = r.budget as Record<string, unknown> | null
const spent = r.spent as Record<string, unknown> | null
return (
typeof r.clientPubkey === 'string' &&
HEX_64.test(r.clientPubkey) &&
Array.isArray(r.relays) &&
r.relays.length > 0 &&
r.relays.every(
relay => typeof relay === 'string' && /^wss?:\/\//.test(relay)
) &&
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'
)
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null
const isPositiveInteger = (value: unknown): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value > 0
const isNonNegativeInteger = (value: unknown): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
const isRelay = (value: unknown): value is string => {
if (typeof value !== 'string') return false
try {
const url = new URL(value)
return url.protocol === 'wss:' || url.protocol === 'ws:'
} catch {
return false
}
}
// malformed entries are dropped, not thrown on - one corrupted record must
// not take the remaining connections down with it
export const readNwcConnections = (): NwcConnectionRecord[] => {
const parseStoredConnection = (value: unknown): StoredNwcConnection | null => {
if (!isRecord(value)) return null
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)
if (!raw) return []
if (raw === null) return []
try {
const parsed: unknown = JSON.parse(raw)
return Array.isArray(parsed)
? parsed.filter(isValidNwcConnectionRecord)
: []
if (!Array.isArray(parsed)) return []
return parsed
.map(parseStoredConnection)
.filter((entry): entry is StoredNwcConnection => entry !== null)
} catch {
return []
}
}
export const writeNwcConnections = (
records: NwcConnectionRecord[]
): void => {
localStorage.setItem(NWC_CONNECTIONS_STORAGE_KEY, JSON.stringify(records))
const storedValue = (entry: StoredNwcConnection): NwcConnectionRecord | LegacyNwcConnectionRecord =>
entry.record
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 = (
record: NwcConnectionRecord
ownerId: unknown,
record: NwcConnectionRecord,
): NwcConnectionRecord => {
const records = readNwcConnections()
const index = records.findIndex(r => r.clientPubkey === record.clientPubkey)
if (!isWalletOwnerId(ownerId) || record.ownerId !== ownerId) {
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
else records.push(record)
writeNwcConnections(records)
writeNwcConnections(ownerId, records)
return record
}
export const removeNwcConnection = (clientPubkey: string): void => {
export const removeNwcConnection = (ownerId: unknown, clientPubkey: string): void => {
if (!isWalletOwnerId(ownerId)) return
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()
}
+87
View File
@@ -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)
}
}