// 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); } }); });