// 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('connection strings', () => { it('round-trips build -> parse, including several relays', () => { const uri = buildConnectionString('ab'.repeat(32), 'cd'.repeat(32), [ 'wss://relay-a.example', 'wss://relay-b.example/path?q=1', ]); expect(uri).toBe( `nostr+walletconnect://${'ab'.repeat(32)}?relay=${encodeURIComponent('wss://relay-a.example')}&relay=${encodeURIComponent('wss://relay-b.example/path?q=1')}&secret=${'cd'.repeat(32)}`, ); expect(parseConnectionString(uri)).toEqual({ walletServicePubkey: 'ab'.repeat(32), clientSecret: 'cd'.repeat(32), relays: ['wss://relay-a.example', 'wss://relay-b.example/path?q=1'], }); }); it('rejects strings that are not connection strings', () => { expect(parseConnectionString('not a uri')).toBeNull(); expect(parseConnectionString('https://example.com')).toBeNull(); // missing secret expect( parseConnectionString(`nostr+walletconnect://${'ab'.repeat(32)}?relay=wss%3A%2F%2Fr.example`), ).toBeNull(); // missing relay expect( parseConnectionString(`nostr+walletconnect://${'ab'.repeat(32)}?secret=${'cd'.repeat(32)}`), ).toBeNull(); // a non-hex pubkey expect( parseConnectionString( 'nostr+walletconnect://zzzz?relay=wss%3A%2F%2Fr.example&secret=' + 'cd'.repeat(32), ), ).toBeNull(); }); it('createConnection returns a string that parses back to the same connection', () => { const connection = createConnection(LINKING_KEY, { relays: RELAYS, budget: { maxMsat: 100_000, periodMs: 86_400_000 }, clientSecret: CLIENT_SECRET, }); const parsed = parseConnectionString(connection.connectionString); expect(parsed).toEqual({ walletServicePubkey: connection.walletServicePubkey, clientSecret: '11'.repeat(32), relays: RELAYS, }); // the record persisted WITHOUT the client secret - it is handed out // once, in the connection string, and never stored const records = readNwcConnections(OWNER_ID); expect(records).toHaveLength(1); expect(requiredValue(records[0]).clientPubkey).toBe(CLIENT_PUBKEY); expect(JSON.stringify(records[0])).not.toContain('11'.repeat(32)); }); }); describe('deriveNwcWalletKey', () => { it('is pinned: derivation changes would silently orphan every connection', () => { expect(bytesToHex(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))).toBe( '71428fc3d77c75f9dc70037283fbed5407cecc44eab56873986a33c24c3e034d', ); expect(getPublicKey(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY))).toBe( 'bf02224dc973a24466ded285c24fb5baf78352b0a2364de7a15b0263fc048bcf', ); }); it('derives a distinct key per client and per linking key', () => { const base = bytesToHex(deriveNwcWalletKey(LINKING_KEY, CLIENT_PUBKEY)); expect(bytesToHex(deriveNwcWalletKey(LINKING_KEY, getPublicKey(STRANGER_SECRET)))).not.toBe( base, ); expect(bytesToHex(deriveNwcWalletKey(OTHER_LINKING_KEY, CLIENT_PUBKEY))).not.toBe(base); }); it('re-derives the same wallet identity from a persisted record after a reinstall', () => { const first = createConnection(LINKING_KEY, { relays: RELAYS, budget: { maxMsat: 100_000, periodMs: 86_400_000 }, clientSecret: CLIENT_SECRET, }); expect(first.walletServicePubkey).toBe( 'bf02224dc973a24466ded285c24fb5baf78352b0a2364de7a15b0263fc048bcf', ); }); });