// 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, allocateOutputSecrets: () => Promise.resolve([]), mintSignatureKeys: () => [], recoverPendingMints: () => Promise.resolve(), reserveMintOutputSource: () => Promise.resolve(), finalizePaymentReturn: () => Promise.resolve(), getBearers: () => [], getDefaultMint: () => null, persistMintOutput: () => Promise.reject(new Error('unused')), discardMintOutput: () => Promise.reject(new Error('unused')), setMintOutputRetirement: () => Promise.reject(new Error('unused')), finalizeMintOutput: () => Promise.reject(new Error('unused')), finalizeSpentMintOutput: () => Promise.reject(new Error('unused')), commitCarve: () => Promise.reject(new Error('unused')), 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, allocateOutputSecrets: () => Promise.resolve([]), mintSignatureKeys: () => [], recoverPendingMints: () => Promise.resolve(), reserveMintOutputSource: () => Promise.resolve(), finalizePaymentReturn: () => Promise.resolve(), getBearers: () => [], getDefaultMint: () => null, persistMintOutput: () => Promise.reject(new Error('unused')), discardMintOutput: () => Promise.reject(new Error('unused')), setMintOutputRetirement: () => Promise.reject(new Error('unused')), finalizeMintOutput: () => Promise.reject(new Error('unused')), finalizeSpentMintOutput: () => Promise.reject(new Error('unused')), commitCarve: () => Promise.reject(new Error('unused')), 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(); }); });