From 33318a5150afd55b5580dd5a15052434aefde90f Mon Sep 17 00:00:00 2001 From: protom Date: Thu, 20 Aug 2026 01:20:41 +0200 Subject: [PATCH] feat: nip-47 nwc wallet service engine with per-connection budgets --- src/lnurlcash/nwc.test.ts | 816 ++++++++++++++++++++++++ src/lnurlcash/nwc.ts | 97 +++ src/lnurlcash/nwc/budget.ts | 37 ++ src/lnurlcash/nwc/connection.ts | 157 +++++ src/lnurlcash/nwc/context.ts | 76 +++ src/lnurlcash/nwc/dispatch.ts | 153 +++++ src/lnurlcash/nwc/invoices.ts | 116 ++++ src/lnurlcash/nwc/pay.ts | 169 +++++ src/lnurlcash/nwc/protocol.ts | 241 +++++++ src/lnurlcash/nwc/service.ts | 190 ++++++ src/lnurlcash/nwc/transport.ts | 40 ++ src/lnurlcash/storage/nwcConnections.ts | 109 ++++ 12 files changed, 2201 insertions(+) create mode 100644 src/lnurlcash/nwc.test.ts create mode 100644 src/lnurlcash/nwc.ts create mode 100644 src/lnurlcash/nwc/budget.ts create mode 100644 src/lnurlcash/nwc/connection.ts create mode 100644 src/lnurlcash/nwc/context.ts create mode 100644 src/lnurlcash/nwc/dispatch.ts create mode 100644 src/lnurlcash/nwc/invoices.ts create mode 100644 src/lnurlcash/nwc/pay.ts create mode 100644 src/lnurlcash/nwc/protocol.ts create mode 100644 src/lnurlcash/nwc/service.ts create mode 100644 src/lnurlcash/nwc/transport.ts create mode 100644 src/lnurlcash/storage/nwcConnections.ts diff --git a/src/lnurlcash/nwc.test.ts b/src/lnurlcash/nwc.test.ts new file mode 100644 index 0000000..58cc9c5 --- /dev/null +++ b/src/lnurlcash/nwc.test.ts @@ -0,0 +1,816 @@ +// 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, + createConnection, + deriveNwcWalletKey, + parseConnectionString, + readNwcConnections, + startService, + writeNwcConnections +} from './nwc' +import type {NostrEvent, NwcConnectionRecord, NwcTransport} from './nwc' +import type {NostrFilter} from './nwc/transport' +import type {NwcChangeset} from './nwc' +import type {Bearer} from './types' +import {stubLocalStorage} from './test-utils' + +const LINKING_KEY = new Uint8Array(32).fill(7) +const OTHER_LINKING_KEY = new Uint8Array(32).fill(9) +const CLIENT_SECRET = hexToBytes('11'.repeat(32)) +const CLIENT_PUBKEY = getPublicKey(CLIENT_SECRET) +const STRANGER_SECRET = hexToBytes('22'.repeat(32)) + +// never connected - the in-memory relay below stands in +const RELAYS = ['wss://relay-a.example'] + +let NOW = 1_800_000_000 +const nowSeconds = (): number => NOW + +const FAST_POLL = {intervalMs: 10, intervalCapMs: 50, maxWaitMs: 5_000} + +// an in-memory relay set: subscriptions register, emit delivers to every +// matching one, publish records +const createFakeRelay = (): { + transport: NwcTransport + published: NostrEvent[] + emit: (event: NostrEvent) => void +} => { + const published: NostrEvent[] = [] + const subs: {filter: NostrFilter; onEvent: (event: NostrEvent) => void}[] = [] + const transport: NwcTransport = { + publish: (_relays, event) => { + published.push(event) + return Promise.resolve() + }, + subscribe: (_relays, filter, onEvent) => { + const sub = {filter, onEvent} + subs.push(sub) + return { + close: () => { + const index = subs.indexOf(sub) + if (index >= 0) subs.splice(index, 1) + } + } + } + } + const emit = (event: NostrEvent): void => { + for (const sub of [...subs]) { + const kindsMatch = + !sub.filter.kinds || sub.filter.kinds.includes(event.kind) + const wanted = sub.filter['#p'] + const pMatch = + !wanted || + event.tags.some(t => t[0] === 'p' && wanted.includes(t[1] ?? '')) + const sinceMatch = + sub.filter.since === undefined || event.created_at >= sub.filter.since + if (kindsMatch && pMatch && sinceMatch) sub.onEvent(event) + } + } + return {transport, published, emit} +} + +type Encryption = 'nip44_v2' | 'nip04' | 'none' + +// a NIP-47 request exactly as a real client would build it, signed by the +// connection's client secret +const clientRequest = ( + walletServicePubkey: string, + content: string, + scheme: Encryption = 'nip44_v2', + createdAt: number = NOW +): NostrEvent => { + const tags: string[][] = [['p', walletServicePubkey]] + if (scheme !== 'none') tags.push(['encryption', scheme]) + return finalizeEvent( + { + kind: NWC_REQUEST_KIND, + created_at: createdAt, + tags, + content: + scheme === 'nip44_v2' + ? nip44v2.encrypt( + content, + nip44v2.utils.getConversationKey(CLIENT_SECRET, walletServicePubkey) + ) + : nip04Encrypt(CLIENT_SECRET, walletServicePubkey, content) + }, + CLIENT_SECRET + ) +} + +const methodRequest = ( + walletServicePubkey: string, + method: string, + params: Record, + scheme: Encryption = 'nip44_v2', + createdAt?: number +): NostrEvent => + clientRequest( + walletServicePubkey, + JSON.stringify({method, params}), + scheme, + createdAt + ) + +// generous: a failed/never-settling melt is only classified after the +// verify-poll budget (seconds) runs out +const waitFor = async (cond: () => boolean): Promise => { + for (let i = 0; i < 3000 && !cond(); i++) { + await new Promise(resolve => setTimeout(resolve, 5)) + } + expect(cond()).toBe(true) +} + +type NwcResponsePayload = { + result_type: string + error: {code: string; message: string} | null + result: Record | null +} + +const readResponse = ( + published: NostrEvent[], + requestId: string, + scheme: Encryption +): NwcResponsePayload | null => { + const event = published.find( + e => + e.kind === NWC_RESPONSE_KIND && + e.tags.some(t => t[0] === 'e' && t[1] === requestId) + ) + if (!event) return null + const plaintext = + scheme === 'nip44_v2' + ? nip44v2.decrypt( + event.content, + nip44v2.utils.getConversationKey(CLIENT_SECRET, event.pubkey) + ) + : nip04Decrypt(CLIENT_SECRET, event.pubkey, event.content) + return JSON.parse(plaintext) as NwcResponsePayload +} + +// drives one full request/response round trip over the fake relay +const call = async ( + relay: ReturnType, + walletServicePubkey: string, + method: string, + params: Record, + scheme: Encryption = 'nip44_v2' +): Promise => { + const request = methodRequest(walletServicePubkey, method, params, scheme) + relay.emit(request) + await waitFor(() => readResponse(relay.published, request.id, scheme) !== null) + return readResponse(relay.published, request.id, scheme)! +} + +type Mint = Awaited> +const mints: Mint[] = [] +const mint = async ( + options: Parameters[0] = {} +): Promise => { + const m = await createMockMint(options) + mints.push(m) + return m +} + +afterEach(async () => { + await Promise.all(mints.splice(0).map(m => m.close())) +}) + +let bearerCounter = 0 +const makeBearer = async (m: Mint, k1: string, amountMsat: number): Promise => { + m.state.creditNote(k1, amountMsat) + const url = buildNoteUrl(`${m.url}/w`, k1, amountMsat) + const info = await fetchNoteInfo(url) + bearerCounter += 1 + return { + id: `bearer-${bearerCounter}`, + url, + callback: info.callback, + amount: info.maxWithdrawable, + verified: true, + mintPubkey: m.state.pubkey, + createdAt: Date.now(), + updatedAt: Date.now() + } +} + +// the harness around startService: a fake relay, an in-memory "store" +// applying changesets the way the Pinia layer will, and a created +// connection with a pinned client secret +const startTestService = async (options: { + budgetMsat?: number + periodMs?: number + defaultMint?: string | null + linkingKey?: Uint8Array + poll?: typeof FAST_POLL +}): Promise<{ + relay: ReturnType + walletServicePubkey: string + state: {bearers: Bearer[]; changesets: NwcChangeset[]; errors: unknown[]} + stop: () => void +}> => { + const budgetMsat = options.budgetMsat ?? 1_000_000_000 + const connection = createConnection(options.linkingKey ?? LINKING_KEY, { + relays: RELAYS, + budget: {maxMsat: budgetMsat, periodMs: options.periodMs ?? 86_400_000}, + clientSecret: CLIENT_SECRET, + now: NOW * 1000 + }) + const relay = createFakeRelay() + const state = { + bearers: [] as Bearer[], + changesets: [] as NwcChangeset[], + errors: [] as unknown[] + } + const service = await startService(options.linkingKey ?? LINKING_KEY, { + getBearers: () => state.bearers, + getDefaultMint: () => options.defaultMint ?? null, + applyChangeset: (changeset: NwcChangeset) => { + state.changesets.push(changeset) + for (const id of changeset.markSpent) { + const bearer = state.bearers.find(b => b.id === id) + if (bearer) bearer.spent = true + } + for (const note of changeset.add) { + bearerCounter += 1 + state.bearers.push({ + ...note, + id: `added-${bearerCounter}`, + createdAt: Date.now(), + updatedAt: Date.now() + }) + } + }, + onError: err => { + state.errors.push(err) + }, + transport: relay.transport, + poll: options.poll ?? FAST_POLL, + claimPoll: FAST_POLL, + nowSeconds + }) + return { + relay, + walletServicePubkey: connection.walletServicePubkey, + state, + stop: service.stop + } +} + +beforeEach(() => { + stubLocalStorage() + NOW = 1_800_000_000 +}) + +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() + expect(records).toHaveLength(1) + expect(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' + ) + }) +}) + +describe('storage validation', () => { + it('drops malformed records instead of throwing', () => { + localStorage.setItem( + 'sattle_nwc_connections', + JSON.stringify([ + {clientPubkey: 'nope'}, + { + clientPubkey: CLIENT_PUBKEY, + relays: RELAYS, + budget: {maxMsat: 1000, periodMs: 1000}, + spent: {periodStart: 0, msat: 0}, + createdAt: 0 + } + ]) + ) + expect(readNwcConnections()).toHaveLength(1) + expect(readNwcConnections()[0]!.clientPubkey).toBe(CLIENT_PUBKEY) + }) + + it('returns nothing for garbage json', () => { + localStorage.setItem('sattle_nwc_connections', '{{{') + expect(readNwcConnections()).toEqual([]) + }) +}) + +describe('service: info and balance', () => { + it('publishes a kind-13194 info event on startup', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const info = relay.published.find(e => e.kind === NWC_INFO_KIND) + expect(info).toBeDefined() + 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']) + 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' + ] + }) + 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}) + stop() + }) +}) + +describe('service: request validation', () => { + it('answers an unknown method with NOT_IMPLEMENTED', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const response = await call(relay, walletServicePubkey, 'get_payments', {}) + expect(response.result_type).toBe('get_payments') + expect(response.error?.code).toBe('NOT_IMPLEMENTED') + expect(response.result).toBeNull() + stop() + }) + + it('answers a malformed (non-JSON) request with an error, not a crash', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const request = clientRequest(walletServicePubkey, 'this is not json') + relay.emit(request) + await waitFor( + () => readResponse(relay.published, request.id, 'nip44_v2') !== null + ) + const response = readResponse(relay.published, request.id, 'nip44_v2')! + expect(response.error?.code).toBe('OTHER') + stop() + }) + + it('answers a JSON request without a method with an error', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const request = clientRequest(walletServicePubkey, JSON.stringify({params: {}})) + relay.emit(request) + await waitFor( + () => readResponse(relay.published, request.id, 'nip44_v2') !== null + ) + expect(readResponse(relay.published, request.id, 'nip44_v2')!.error?.code).toBe( + 'OTHER' + ) + stop() + }) + + it('ignores a request signed by a stranger key - silently', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const content = nip44v2.encrypt( + JSON.stringify({method: 'get_balance', params: {}}), + nip44v2.utils.getConversationKey(STRANGER_SECRET, walletServicePubkey) + ) + const forged = finalizeEvent( + { + kind: NWC_REQUEST_KIND, + created_at: NOW, + tags: [['p', walletServicePubkey], ['encryption', 'nip44_v2']], + content + }, + STRANGER_SECRET + ) + relay.emit(forged) + await new Promise(resolve => setTimeout(resolve, 100)) + expect( + relay.published.filter(e => e.kind === NWC_RESPONSE_KIND) + ).toHaveLength(0) + stop() + }) + + it('answers an unsupported encryption scheme with UNSUPPORTED_ENCRYPTION', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const content = nip04Encrypt( + CLIENT_SECRET, + walletServicePubkey, + JSON.stringify({method: 'get_balance', params: {}}) + ) + const request = finalizeEvent( + { + kind: NWC_REQUEST_KIND, + created_at: NOW, + tags: [['p', walletServicePubkey], ['encryption', 'nip17']], + content + }, + CLIENT_SECRET + ) + relay.emit(request) + // the error answer goes out in the legacy scheme every client reads + await waitFor( + () => readResponse(relay.published, request.id, 'nip04') !== null + ) + expect( + readResponse(relay.published, request.id, 'nip04')!.error?.code + ).toBe('UNSUPPORTED_ENCRYPTION') + stop() + }) + + it('speaks legacy NIP-04: no encryption tag, and an explicit nip04 tag', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + for (const scheme of ['none', 'nip04'] as const) { + const request = methodRequest(walletServicePubkey, 'get_balance', {}, scheme) + relay.emit(request) + await waitFor( + () => readResponse(relay.published, request.id, 'nip04') !== null + ) + const response = readResponse(relay.published, request.id, 'nip04')! + expect(response.error).toBeNull() + expect(response.result).toEqual({balance: 0}) + // the response mirrors the request's scheme + const event = relay.published.find( + e => + e.kind === NWC_RESPONSE_KIND && + e.tags.some(t => t[0] === 'e' && t[1] === request.id) + )! + expect(event.tags).toContainEqual(['encryption', 'nip04']) + } + stop() + }) + + it('drops requests older than the replay window unanswered', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const stale = methodRequest( + walletServicePubkey, + 'get_balance', + {}, + 'nip44_v2', + NOW - 1200 + ) + relay.emit(stale) + await new Promise(resolve => setTimeout(resolve, 100)) + expect( + relay.published.filter(e => e.kind === NWC_RESPONSE_KIND) + ).toHaveLength(0) + stop() + }) + + it('picks up no new requests after stop', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + stop() + const request = methodRequest(walletServicePubkey, 'get_balance', {}) + relay.emit(request) + await new Promise(resolve => setTimeout(resolve, 100)) + expect( + relay.published.filter(e => e.kind === NWC_RESPONSE_KIND) + ).toHaveLength(0) + }) +}) + +describe('service: pay_invoice', () => { + it('pays a bolt11 by melting, returning the melt preimage and recording the spend', async () => { + const m = await mint() + const {relay, walletServicePubkey, state, stop} = await startTestService({ + budgetMsat: 50_000 + }) + state.bearers = [await makeBearer(m, 'dd'.repeat(32), 21_000)] + + const response = await call(relay, walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc210n1pjqrstuvwxyz' + }) + expect(response.error).toBeNull() + expect(typeof response.result?.preimage).toBe('string') + expect((response.result?.preimage as string).length).toBe(64) + + // the note is gone (melted) and locked spent via the changeset + expect(m.state.noteState('dd'.repeat(32))).toBe('burned') + expect(state.bearers[0]!.spent).toBe(true) + + // the spend was recorded against the budget, persisted + expect(readNwcConnections()[0]!.spent.msat).toBe(21_000) + stop() + }) + + it('rejects a payment over the connection budget with QUOTA_EXCEEDED', async () => { + const m = await mint() + const {relay, walletServicePubkey, state, stop} = await startTestService({ + budgetMsat: 20_000 + }) + state.bearers = [await makeBearer(m, 'ee'.repeat(32), 21_000)] + + const response = await call(relay, walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc210n1pjqrstuvwxyz' + }) + expect(response.error?.code).toBe('QUOTA_EXCEEDED') + // nothing moved: the note is untouched, no spend recorded + expect(m.state.noteState('ee'.repeat(32))).toBe('outstanding') + expect(state.bearers[0]!.spent).toBeUndefined() + expect(readNwcConnections()[0]!.spent.msat).toBe(0) + stop() + }) + + it('resets the allowance once the budget period has rolled over', async () => { + const m = await mint() + const {relay, walletServicePubkey, state, stop} = await startTestService({ + budgetMsat: 21_000, + periodMs: 60_000 + }) + // simulate a fully spent budget from a period that ended long ago + const record: NwcConnectionRecord = readNwcConnections()[0]! + writeNwcConnections([ + {...record, spent: {periodStart: Date.now() - 120_000, msat: 21_000}} + ]) + state.bearers = [await makeBearer(m, 'ef'.repeat(32), 21_000)] + + const response = await call(relay, walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc210n1pjqrstuvwxyz' + }) + expect(response.error).toBeNull() + expect(readNwcConnections()[0]!.spent.msat).toBe(21_000) + stop() + }) + + it('rejects a payment the wallet cannot cover with INSUFFICIENT_BALANCE', async () => { + const m = await mint() + const {relay, walletServicePubkey, state, stop} = await startTestService({}) + state.bearers = [await makeBearer(m, 'ff'.repeat(32), 5_000)] + + const response = await call(relay, walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc210n1pjqrstuvwxyz' + }) + expect(response.error?.code).toBe('INSUFFICIENT_BALANCE') + expect(m.state.noteState('ff'.repeat(32))).toBe('outstanding') + stop() + }) + + it('rejects a request amount that mismatches the invoice amount', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const response = await call(relay, walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc210n1pjqrstuvwxyz', + amount: 5_000 + }) + expect(response.error?.code).toBe('OTHER') + expect(response.error?.message).toMatch(/match/i) + stop() + }) + + it('rejects an amount-less invoice instead of guessing', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const response = await call(relay, walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc1pjqrstuvwxyz' + }) + expect(response.error?.code).toBe('OTHER') + expect(response.error?.message).toMatch(/amount/i) + stop() + }) + + it('answers a failed melt with PAYMENT_FAILED and tracks the returned funds', async () => { + const m = await mint({meltAlwaysFails: true}) + const {relay, walletServicePubkey, state, stop} = await startTestService({ + // a short verify budget: the failed melt is classified by the poll + // running out, and that wait is the test's own clock + poll: {intervalMs: 10, intervalCapMs: 20, maxWaitMs: 300} + }) + state.bearers = [await makeBearer(m, '01'.repeat(32), 21_000)] + + const response = await call(relay, walletServicePubkey, 'pay_invoice', { + invoice: 'lnbc210n1pjqrstuvwxyz' + }) + expect(response.error?.code).toBe('PAYMENT_FAILED') + + // the funds came back, re-secured: the old secret burned, a fresh one + // tracked unspent via the changeset - and no budget spend recorded + expect(m.state.noteState('01'.repeat(32))).toBe('burned') + const returned = state.bearers.find(b => b.id.startsWith('added-')) + expect(returned).toBeDefined() + expect(returned!.spent).toBeUndefined() + expect(returned!.amount).toBe(21_000) + expect(m.state.noteState(noteK1(returned!.url)!)).toBe('outstanding') + expect(readNwcConnections()[0]!.spent.msat).toBe(0) + stop() + }) +}) + +describe('service: make_invoice / lookup_invoice', () => { + it('issues an invoice, settles it in the background, and reports the preimage', async () => { + const m = await mint({testHooks: true}) + const {relay, walletServicePubkey, state, stop} = await startTestService({ + defaultMint: `mint@127.0.0.1:${m.port}` + }) + + const made = await call(relay, walletServicePubkey, 'make_invoice', { + amount: 21_000, + description: 'nwc test', + expiry: 3600 + }) + expect(made.error).toBeNull() + expect(made.result).toMatchObject({ + type: 'incoming', + state: 'pending', + amount: 21_000, + description: 'nwc test', + created_at: NOW, + expires_at: NOW + 3600 + }) + const invoice = made.result!.invoice as string + const paymentHash = made.result!.payment_hash as string + expect(invoice).toMatch(/^lnbc/) + expect(paymentHash).toMatch(/^[0-9a-f]{64}$/) + + // before settlement the lookup reports the pending invoice + const pending = await call(relay, walletServicePubkey, 'lookup_invoice', { + payment_hash: paymentHash + }) + expect(pending.error).toBeNull() + expect(pending.result?.state).toBe('pending') + expect(pending.result?.preimage).toBeUndefined() + + // the "payer" pays the invoice; the background claim settles and + // mints the note + const settleRes = await fetch( + `${m.url}/_test/settle?payment_hash=${paymentHash}` + ) + expect(settleRes.ok).toBe(true) + await waitFor(() => + state.changesets.some(c => c.add.length > 0) + ) + + const settled = await call(relay, walletServicePubkey, 'lookup_invoice', { + payment_hash: paymentHash + }) + expect(settled.error).toBeNull() + expect(settled.result?.state).toBe('settled') + expect(settled.result?.settled_at).toBe(NOW) + const preimage = settled.result?.preimage as string + expect(preimage).toMatch(/^[0-9a-f]{64}$/) + + // the minted note was claimed AND rotated before settlement was + // recorded: the preimage the client just learned is a burned secret, + // and the wallet's fresh note is the only live one + expect(m.state.noteState(preimage)).toBe('burned') + const minted = state.bearers.find(b => b.id.startsWith('added-'))! + expect(minted.amount).toBe(21_000) + expect(minted.verified).toBe(true) + expect(noteK1(minted.url)).not.toBe(preimage) + expect(m.state.noteState(noteK1(minted.url)!)).toBe('outstanding') + stop() + }) + + it('finds an invoice by its invoice string too', async () => { + const m = await mint({testHooks: true}) + const {relay, walletServicePubkey, stop} = await startTestService({ + defaultMint: `mint@127.0.0.1:${m.port}` + }) + const made = await call(relay, walletServicePubkey, 'make_invoice', { + amount: 5_000 + }) + const found = await call(relay, walletServicePubkey, 'lookup_invoice', { + invoice: (made.result!.invoice as string).toUpperCase() + }) + expect(found.error).toBeNull() + expect(found.result?.payment_hash).toBe(made.result!.payment_hash) + stop() + }) + + it('answers an unknown invoice with NOT_FOUND', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const response = await call(relay, walletServicePubkey, 'lookup_invoice', { + payment_hash: 'ab'.repeat(32) + }) + expect(response.error?.code).toBe('NOT_FOUND') + stop() + }) + + it('answers make_invoice without a default mint with INTERNAL', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({ + defaultMint: null + }) + const response = await call(relay, walletServicePubkey, 'make_invoice', { + amount: 21_000 + }) + expect(response.error?.code).toBe('INTERNAL') + stop() + }) + + it('answers a make_invoice with a bad amount with OTHER', async () => { + const {relay, walletServicePubkey, stop} = await startTestService({}) + const response = await call(relay, walletServicePubkey, 'make_invoice', { + amount: -5 + }) + expect(response.error?.code).toBe('OTHER') + stop() + }) +}) diff --git a/src/lnurlcash/nwc.ts b/src/lnurlcash/nwc.ts new file mode 100644 index 0000000..850230e --- /dev/null +++ b/src/lnurlcash/nwc.ts @@ -0,0 +1,97 @@ +// NWC (Nostr Wallet Connect, NIP-47): the wallet-SERVICE side. Clients +// (Alby-style apps) hold a connection string and talk to this wallet over +// public relays with end-to-end-encrypted request/response events; this +// engine validates, decrypts, dispatches onto the ops engine and answers. +// +// FOREGROUND-ONLY, by design: the service runs only while the app is open +// and the wallet unlocked (the wallet-service keys derive from the linking +// key, which only exists in memory then). There is no background runner +// and no push channel: requests sent while the app is closed wait on the +// relay, and any request older than ten minutes when it finally arrives +// is DROPPED UNANSWERED rather than executed late (a pay_invoice executed +// after the client gave up could double-pay). An always-on sattle would +// need a headless signer holding a derived key - deliberately out of +// scope for the PWA. +// +// Framework-free, and no WebSocket is touched at import time: the default +// transport (nostr-tools' SimplePool) is imported lazily on first use, +// and tests inject a fake transport instead. nostr-tools was chosen over +// @getalby/sdk on purpose: the wallet-service side needs exactly event +// signing, NIP-44/NIP-04 crypto and a subscription - all already pinned - +// and the sdk's own websocket management would fight this project's +// injectable-transport pattern. +// +// Split by concern; this façade re-exports everything: +// nwc/connection.ts - createConnection, the deterministic +// wallet-service key derivation, and the +// connection-string codec +// nwc/protocol.ts - the pure NIP-47 event codec (validate/decrypt, +// build/encrypt), no I/O +// nwc/transport.ts - the injectable relay transport +// nwc/context.ts - the shared deps/changeset/invoice-registry types +// nwc/budget.ts - the per-connection rolling budget +// nwc/invoices.ts - the two-phase invoice registry + background claim +// nwc/pay.ts - pay_invoice (budget-first melt) +// nwc/dispatch.ts - the remaining method handlers + the dispatch switch +// nwc/service.ts - startService/stopService: subscriptions and the +// per-connection request queues +// Budget/connection persistence lives in storage/nwcConnections.ts. + +export type { + NwcBudget, + NwcBudgetSpend, + NwcConnectionRecord +} from './storage/nwcConnections' +export { + persistNwcConnection, + readNwcConnections, + removeNwcConnection, + writeNwcConnections +} from './storage/nwcConnections' + +export { + buildConnectionString, + connectionInfoOf, + createConnection, + deriveNwcWalletKey, + nwcWalletPubkey, + parseConnectionString +} from './nwc/connection' +export type { + CreateConnectionOptions, + CreatedConnection, + NwcConnectionInfo, + ParsedConnectionString +} from './nwc/connection' + +export { + NWC_INFO_KIND, + NWC_METHODS, + NWC_REQUEST_KIND, + NWC_RESPONSE_KIND, + buildInfoEvent, + buildResponseEvent, + decryptRequest, + errResult, + okResult +} from './nwc/protocol' +export type { + DecryptedNwcRequest, + NostrEvent, + NwcEncryption, + NwcErrorCode, + NwcMethod, + NwcRequest, + NwcResponse +} from './nwc/protocol' + +export {defaultNwcTransport} from './nwc/transport' +export type {NostrFilter, NwcSubscription, NwcTransport} from './nwc/transport' + +export {budgetRemainingMsat, recordSpend} from './nwc/budget' +export type {PendingInvoice, RequestContext} from './nwc/context' +export {invoiceResult, resolvePaymentHash} from './nwc/invoices' +export {payChangeset} from './nwc/pay' + +export {startService} from './nwc/service' +export type {NwcChangeset, NwcService, NwcServiceDeps} from './nwc/service' diff --git a/src/lnurlcash/nwc/budget.ts b/src/lnurlcash/nwc/budget.ts new file mode 100644 index 0000000..74320ee --- /dev/null +++ b/src/lnurlcash/nwc/budget.ts @@ -0,0 +1,37 @@ +// The per-connection NWC budget: max msat per rolling period, persisted +// in storage/nwcConnections so a restart (or a browser crash mid-session) +// doesn't reset a client's allowance. The service serializes pay_invoice +// per connection through its request queue, which is what makes the +// check + record below atomic enough: no two pays of one connection ever +// run this concurrently. + +import type {NwcConnectionRecord} from '../storage/nwcConnections' +import {persistNwcConnection} from '../storage/nwcConnections' + +export const budgetRemainingMsat = ( + record: NwcConnectionRecord, + nowMs: number +): number => { + const {maxMsat, periodMs} = record.budget + if (nowMs - record.spent.periodStart >= periodMs) return maxMsat + return Math.max(0, maxMsat - record.spent.msat) +} + +// rolls the period when it expired, then adds the spend; persists (the +// caller's queue serialized this read-modify-write) +export const recordSpend = ( + record: NwcConnectionRecord, + amountMsat: number, + nowMs: number +): NwcConnectionRecord => { + const expired = nowMs - record.spent.periodStart >= record.budget.periodMs + return persistNwcConnection({ + ...record, + spent: expired + ? {periodStart: nowMs, msat: amountMsat} + : { + periodStart: record.spent.periodStart, + msat: record.spent.msat + amountMsat + } + }) +} diff --git a/src/lnurlcash/nwc/connection.ts b/src/lnurlcash/nwc/connection.ts new file mode 100644 index 0000000..dec0c16 --- /dev/null +++ b/src/lnurlcash/nwc/connection.ts @@ -0,0 +1,157 @@ +// NWC connections: the model half of the NIP-47 wallet service - creating +// connections, deriving their keys, and the connection-string codec. No +// relay I/O here; the runtime lives in service.ts. +// +// Key material, per connection: +// - the CLIENT keypair: a fresh random secret, handed to the client app +// exactly once inside the connection string. The wallet stores only its +// pubkey (NIP-47: the wallet service should not store the secret it +// generates for the client) - a leaked device backup then can't +// impersonate a client, and a lost connection string simply means +// creating a new connection. +// - the WALLET-SERVICE keypair: derived, not generated: +// sha256(linking key || context || client pubkey), the same construction +// as nostr/events.ts's deriveBackupKey under a different context string. +// Deterministic derivation is what lets a restored seed serve its old +// connections again without any extra backup: the persisted record +// (client pubkey, relays, budget) re-yields the identical wallet key, so +// the client keeps talking to the same wallet-service pubkey. +// +// The connection string follows NIP-47 exactly: +// nostr+walletconnect://?relay=wss://...&secret= + +import {sha256} from '@noble/hashes/sha2.js' +import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' +import {getPublicKey} from 'nostr-tools/pure' + +import type {NwcBudget, NwcConnectionRecord} from '../storage/nwcConnections' +import {persistNwcConnection} from '../storage/nwcConnections' + +const NWC_WALLET_KEY_CONTEXT = 'sattle-nwc-wallet-v1' + +const HEX_64 = /^[0-9a-f]{64}$/i + +// Deterministic: sha256(linking key || context || client pubkey). The +// result is a secp256k1 secret key used ONLY as this connection's +// wallet-service identity - it signs and decrypts NIP-47 events for this +// one client, nothing else. +export const deriveNwcWalletKey = ( + linkingPrivKey: Uint8Array, + clientPubkey: string +): Uint8Array => + sha256( + new Uint8Array([ + ...linkingPrivKey, + ...utf8ToBytes(NWC_WALLET_KEY_CONTEXT), + ...hexToBytes(clientPubkey) + ]) + ) + +// the x-only nostr pubkey the client addresses its requests to +export const nwcWalletPubkey = (walletSecretKey: Uint8Array): string => + getPublicKey(walletSecretKey) + +export type NwcConnectionInfo = { + record: NwcConnectionRecord + walletServicePubkey: string +} + +// the runtime view of a persisted record: the record plus its re-derived +// wallet-service identity +export const connectionInfoOf = ( + linkingPrivKey: Uint8Array, + record: NwcConnectionRecord +): NwcConnectionInfo => ({ + record, + walletServicePubkey: nwcWalletPubkey( + deriveNwcWalletKey(linkingPrivKey, record.clientPubkey) + ) +}) + +export type CreatedConnection = NwcConnectionInfo & { + // shown to the holder exactly once - it carries the client secret, + // which the wallet deliberately does NOT store + connectionString: string +} + +export type CreateConnectionOptions = { + relays: string[] + budget: NwcBudget + // test hook: a fixed client secret (32 bytes) instead of a random one + clientSecret?: Uint8Array + now?: number +} + +// Creates and persists a connection. The client secret is random by +// default; the wallet-service key falls out of the derivation above. +export const createConnection = ( + linkingPrivKey: Uint8Array, + options: CreateConnectionOptions +): CreatedConnection => { + if (options.relays.length === 0) { + throw new Error('A connection needs at least one relay.') + } + const clientSecret = + options.clientSecret ?? crypto.getRandomValues(new Uint8Array(32)) + const clientPubkey = getPublicKey(clientSecret) + const record = persistNwcConnection({ + clientPubkey, + relays: options.relays, + budget: options.budget, + spent: {periodStart: options.now ?? Date.now(), msat: 0}, + createdAt: options.now ?? Date.now() + }) + const info = connectionInfoOf(linkingPrivKey, record) + return { + ...info, + connectionString: buildConnectionString( + info.walletServicePubkey, + bytesToHex(clientSecret), + record.relays + ) + } +} + +export const buildConnectionString = ( + walletServicePubkey: string, + clientSecretHex: string, + relays: string[] +): string => { + const query = relays + .map(relay => `relay=${encodeURIComponent(relay)}`) + .join('&') + return `nostr+walletconnect://${walletServicePubkey}?${query}&secret=${clientSecretHex}` +} + +export type ParsedConnectionString = { + walletServicePubkey: string + clientSecret: string + relays: string[] +} + +// parses a NIP-47 connection string; returns null for anything that isn't +// exactly one (a client-side counterpart of buildConnectionString, here so +// the format has a tested inverse) +export const parseConnectionString = ( + uri: string +): ParsedConnectionString | null => { + let url: URL + try { + url = new URL(uri.trim()) + } catch { + return null + } + if (url.protocol !== 'nostr+walletconnect:') return null + // the host is the wallet-service pubkey; normalize case so a + // hand-typed uppercase string still parses (nostr pubkeys are + // conventionally lowercase hex) + const walletServicePubkey = url.host.toLowerCase() + if (!HEX_64.test(walletServicePubkey)) return null + const secret = url.searchParams.get('secret') + if (!secret || !HEX_64.test(secret)) return null + const relays = url.searchParams + .getAll('relay') + .filter(relay => /^wss?:\/\//.test(relay)) + if (relays.length === 0) return null + return {walletServicePubkey, clientSecret: secret.toLowerCase(), relays} +} diff --git a/src/lnurlcash/nwc/context.ts b/src/lnurlcash/nwc/context.ts new file mode 100644 index 0000000..9541cfb --- /dev/null +++ b/src/lnurlcash/nwc/context.ts @@ -0,0 +1,76 @@ +// The shared types of the NWC service runtime: the deps the caller +// provides, the changeset the caller's store applies after an op ran, the +// pending-invoice registry entry, and the per-request context the method +// handlers (dispatch.ts, pay.ts, invoices.ts) operate on. Types only - +// no logic, so every other nwc/ module can import from here without +// cycles. + +import type {LnurlcashOptions} from 'lnurlcash-kit' + +import type {Bearer, NewBearer} from '../types' +import type {NwcConnectionRecord} from '../storage/nwcConnections' +import type {PreparedMint} from '../ops' +import type {PollOptions} from '../ops/shared' + +import type {NwcConnectionInfo} from './connection' +import type {NwcMethod} from './protocol' +import type {NwcTransport} from './transport' + +// the state delta the caller's store applies after an op ran: new notes +// to track, existing bearer ids to lock spent. Born-spent notes (a +// freshly carved note that was melted away) are deliberately NOT part of +// it - they were never the wallet's money in a trackable state +export type NwcChangeset = { + add: NewBearer[] + markSpent: string[] +} + +export type NwcServiceDeps = { + getBearers: () => Bearer[] + // the mint make_invoice issues invoices against (the wallet's default + // mint - NIP-47's make_invoice carries no mint choice) + getDefaultMint: () => string | null + applyChangeset: ( + changeset: NwcChangeset, + connection: NwcConnectionInfo, + method: NwcMethod + ) => void + transport?: NwcTransport + // kit transport overrides (fetch injection, timeouts) + kit?: LnurlcashOptions + // verify-poll budget for pay_invoice outcome classification + poll?: PollOptions + // verify-poll budget for the background claim after make_invoice - + // generous by default: the client pays the invoice whenever it pays it + claimPoll?: PollOptions + // background failures (a lost claim, a rejected publish) have no caller + // to throw to - they surface here + onError?: (error: unknown, connection: NwcConnectionInfo) => void + // test hook: pinned clock for event timestamps and expirations + nowSeconds?: () => number +} + +export type PendingInvoice = { + invoice: string + paymentHash: string + // the gross invoiced amount (net + mint fee) - what the payer pays + amountMsat: number + description?: string + createdAt: number // unix seconds + expiresAt?: number + prepared: PreparedMint + state: 'pending' | 'settled' | 'failed' + preimage?: string + settledAt?: number +} + +// what a method handler sees of its connection's runtime: the deps, the +// (fresh) connection info - budget updates replace the record, so it's a +// getter, not a snapshot - the invoice registry, and the clock +export type RequestContext = { + deps: NwcServiceDeps + connection: () => NwcConnectionInfo + updateRecord: (record: NwcConnectionRecord) => void + invoices: Map + nowSeconds: () => number +} diff --git a/src/lnurlcash/nwc/dispatch.ts b/src/lnurlcash/nwc/dispatch.ts new file mode 100644 index 0000000..36651ea --- /dev/null +++ b/src/lnurlcash/nwc/dispatch.ts @@ -0,0 +1,153 @@ +// Method dispatch: get_info / get_balance / make_invoice / +// lookup_invoice live here directly, pay_invoice in pay.ts (it carries +// the budget logic). Bearers always come IN through deps.getBearers and +// results go OUT through deps.applyChangeset - this engine never touches +// wallet state itself. + +import {noteK1, sameInvoice} from 'lnurlcash-kit' + +import type {Bearer} from '../types' +import type {PreparedMint} from '../ops' +import {prepareMint} from '../ops' + +import type {PendingInvoice, RequestContext} from './context' +import {invoiceResult, resolvePaymentHash, settleAndClaim} from './invoices' +import {handlePayInvoice} from './pay' +import type {NwcRequest, NwcResponse} from './protocol' +import {NWC_METHODS, errResult, okResult} from './protocol' + +// the same eligibility carve applies - the balance answers "what could +// this wallet actually pay with right now" +const spendable = (bearer: Bearer): boolean => + !bearer.spent && + bearer.callback !== '' && + !bearer.deviceId && + !!noteK1(bearer.url) + +const handleGetInfo = (ctx: RequestContext): NwcResponse => + okResult('get_info', { + alias: 'sattle', + color: '#55ffcc', + pubkey: ctx.connection().walletServicePubkey, + // a bearer-note wallet has no chain view of its own - the network is + // whatever the mints' invoices say, mainnet in practice. No honest + // block height/hash exists, so those fields are simply absent. + network: 'mainnet', + methods: [...NWC_METHODS], + notifications: [] + }) + +const handleGetBalance = (ctx: RequestContext): NwcResponse => + okResult('get_balance', { + balance: ctx.deps + .getBearers() + .filter(spendable) + .reduce((sum, b) => sum + b.amount, 0) + }) + +const handleMakeInvoice = async ( + ctx: RequestContext, + params: Record +): Promise => { + const amountMsat = Number(params.amount) + if (!Number.isInteger(amountMsat) || amountMsat <= 0) { + return errResult( + 'make_invoice', + 'OTHER', + 'Amount must be a positive whole number of msat.' + ) + } + const mint = ctx.deps.getDefaultMint() + if (!mint) { + return errResult('make_invoice', 'INTERNAL', 'No default mint is configured.') + } + let prepared: PreparedMint + try { + prepared = await prepareMint(mint, amountMsat, ctx.deps.kit ?? {}) + } catch (err) { + return errResult( + 'make_invoice', + 'INTERNAL', + err instanceof Error ? err.message : String(err) + ) + } + const entry: PendingInvoice = { + invoice: prepared.invoice, + paymentHash: resolvePaymentHash(prepared), + amountMsat: prepared.grossMsat, + createdAt: ctx.nowSeconds(), + prepared, + state: 'pending' + } + if (typeof params.description === 'string' && params.description) { + entry.description = params.description + } + const expiry = Number(params.expiry) + if (Number.isInteger(expiry) && expiry > 0) { + entry.expiresAt = entry.createdAt + expiry + } + ctx.invoices.set(entry.paymentHash, entry) + // phase two runs in the background; the invoice goes out now and + // lookup_invoice reports the settlement the claim observes + void settleAndClaim(ctx, entry).catch(err => { + entry.state = 'failed' + ctx.deps.onError?.(err, ctx.connection()) + }) + return okResult('make_invoice', invoiceResult(entry)) +} + +const handleLookupInvoice = ( + ctx: RequestContext, + params: Record +): NwcResponse => { + const invoiceParam = + typeof params.invoice === 'string' ? params.invoice : undefined + const hashParam = + typeof params.payment_hash === 'string' + ? params.payment_hash.toLowerCase() + : undefined + if (!invoiceParam && !hashParam) { + return errResult( + 'lookup_invoice', + 'OTHER', + 'Provide an invoice or a payment hash.' + ) + } + let entry = hashParam ? ctx.invoices.get(hashParam) : undefined + if (!entry && invoiceParam) { + for (const candidate of ctx.invoices.values()) { + if (sameInvoice(candidate.invoice, invoiceParam)) { + entry = candidate + break + } + } + } + if (!entry) { + return errResult('lookup_invoice', 'NOT_FOUND', 'Unknown invoice.') + } + return okResult('lookup_invoice', invoiceResult(entry)) +} + +export const dispatch = async ( + ctx: RequestContext, + request: NwcRequest +): Promise => { + switch (request.method) { + case 'get_info': + return handleGetInfo(ctx) + case 'get_balance': + return handleGetBalance(ctx) + case 'make_invoice': + return await handleMakeInvoice(ctx, request.params) + case 'pay_invoice': + return await handlePayInvoice(ctx, request.params) + case 'lookup_invoice': + return handleLookupInvoice(ctx, request.params) + default: + return errResult( + request.method, + 'NOT_IMPLEMENTED', + `Unknown method: ${request.method}.` + ) + } +} diff --git a/src/lnurlcash/nwc/invoices.ts b/src/lnurlcash/nwc/invoices.ts new file mode 100644 index 0000000..92cd9da --- /dev/null +++ b/src/lnurlcash/nwc/invoices.ts @@ -0,0 +1,116 @@ +// The invoice registry the two-phase make_invoice / lookup_invoice pair +// shares: make_invoice answers with the invoice immediately while this +// module's settleAndClaim watches settlement in the background, claims +// the minted note (rotating it immediately), and only THEN records the +// settlement the lookup reports. +// +// THE PREIMAGE, in lnurlcash terms: the payment preimage IS the minted +// note's initial secret. NIP-47 clients expect it as the settlement +// receipt, so it is handed out - but only after claimFromPreimage has +// rotated the fresh note, when that secret is burned and worthless. If +// the rotation failed (claimed.rotated === false) the preimage is +// withheld: fund safety over spec comfort. + +import {isPreimage, sameInvoice} from 'lnurlcash-kit' +import {sha256} from '@noble/hashes/sha2.js' +import {bytesToHex, utf8ToBytes} from '@noble/hashes/utils.js' + +import type {NewBearer} from '../types' +import type {PreparedMint} from '../ops' +import {claimFromPreimage} from '../ops/mint' +import {pollVerifyUntilSettled} from '../ops/shared' +import type {PollOptions} from '../ops/shared' + +import type {PendingInvoice, RequestContext} from './context' + +export const DEFAULT_CLAIM_POLL: Required = { + intervalMs: 2000, + intervalCapMs: 10_000, + maxWaitMs: 15 * 60_000 +} + +// LUD-21 verify URLs end in /verify/ (the protocol's verify +// convention) - that suffix is the invoice's real payment hash. A service +// that shapes its verify URL differently gets a wallet-local correlation +// id instead (sha256 of the invoice): still unique and stable for +// make_invoice <-> lookup_invoice correlation, just not the on-chain hash. +export const resolvePaymentHash = (prepared: PreparedMint): string => { + const fromVerify = prepared.verifyUrl?.match(/\/([0-9a-f]{64})$/i)?.[1] + if (fromVerify) return fromVerify.toLowerCase() + return bytesToHex(sha256(utf8ToBytes(prepared.invoice))) +} + +// the NIP-47 transaction object make_invoice and lookup_invoice share +export const invoiceResult = ( + entry: PendingInvoice +): Record => { + const result: Record = { + type: 'incoming', + state: entry.state, + invoice: entry.invoice, + payment_hash: entry.paymentHash, + amount: entry.amountMsat, + created_at: entry.createdAt, + metadata: {} + } + if (entry.description) result.description = entry.description + if (entry.expiresAt) result.expires_at = entry.expiresAt + if (entry.state === 'settled' && entry.preimage) { + result.preimage = entry.preimage + } + if (entry.settledAt) result.settled_at = entry.settledAt + return result +} + +// the background half of make_invoice: watch the invoice, and once it +// settles claim the note (rotating it immediately) and hand the fresh +// bearer to the caller. Settlement is recorded LAST - after the rotate - +// so a preimage lookup can only ever reveal an already-burned secret. +// Throws on any failure; the caller marks the entry failed and reports +// through deps.onError. +export const settleAndClaim = async ( + ctx: RequestContext, + entry: PendingInvoice +): Promise => { + if (!entry.prepared.verifyUrl) { + throw new Error( + 'This mint did not advertise a verify URL - the invoice cannot be auto-claimed.' + ) + } + const result = await pollVerifyUntilSettled( + entry.prepared.verifyUrl, + ctx.deps.claimPoll ?? DEFAULT_CLAIM_POLL, + ctx.deps.kit ?? {} + ) + // a settled report only means this wallet's invoice was paid if it's + // for the invoice this wallet actually requested + if (!sameInvoice(result.pr, entry.prepared.invoice)) { + throw new Error( + "The service's verify response is for a different invoice than requested." + ) + } + const preimage = result.preimage + if (!preimage || !isPreimage(preimage)) { + throw new Error( + 'The payment settled but the service did not reveal the preimage.' + ) + } + // claimFromPreimage IS claimMintedNote's claim half (poll above is the + // other half) - invoked in two steps here because NWC needs the + // preimage, which claimMintedNote deliberately discards + const claimed = await claimFromPreimage( + entry.prepared, + preimage, + ctx.deps.kit ?? {} + ) + const add: NewBearer[] = [claimed.note] + if (claimed.possibleCopy) add.push(claimed.possibleCopy) + entry.settledAt = ctx.nowSeconds() + entry.state = 'settled' + if (claimed.rotated) { + // see the header: only a rotated note makes the preimage a worthless + // secret, safe to hand out as the settlement receipt + entry.preimage = preimage + } + ctx.deps.applyChangeset({add, markSpent: []}, ctx.connection(), 'make_invoice') +} diff --git a/src/lnurlcash/nwc/pay.ts b/src/lnurlcash/nwc/pay.ts new file mode 100644 index 0000000..b3d2192 --- /dev/null +++ b/src/lnurlcash/nwc/pay.ts @@ -0,0 +1,169 @@ +// pay_invoice: melt held notes into the client's bolt11, budget-first. +// The budget check and the spend record run inside the connection's +// request queue (service.ts serializes pay_invoice), so no two pays of +// one connection can interleave them. Spends are recorded for settled +// AND unknown-still-pending outcomes - pessimistic on purpose: the money +// may be in flight; a failed payment records nothing. + +import { + decodeBolt11AmountMsat, + fetchInvoiceVerification, + isBolt11Invoice, + noteK1 +} from 'lnurlcash-kit' + +import type {Bearer, NewBearer} from '../types' +import type {PayResult} from '../ops' +import {UncertainOutcomeError, payWithBearers} from '../ops' + +import {budgetRemainingMsat, recordSpend} from './budget' +import type {NwcChangeset, RequestContext} from './context' +import type {NwcResponse} from './protocol' +import {errResult, okResult} from './protocol' + +// maps a PayResult onto the store delta, mirroring PayInvoiceDialog's +// semantics: carve inputs lock spent, change is tracked, returned funds +// are tracked UNMARKED, rescued secrets are tracked unverified. One +// deliberate divergence: in the exact-match + funds-returned case the +// original bearer (old k1, burned server-side by the classification +// rotate) is left for the next refresh to reconcile, exactly as the UI +// leaves it - the money itself sits in the re-secured note, which IS +// tracked. +export const payChangeset = ( + bearers: Bearer[], + result: PayResult +): NwcChangeset => { + const add: NewBearer[] = [] + const markSpent: string[] = result.carve.consumed.map(b => b.id) + if (result.carve.change) add.push(result.carve.change) + if (result.outcome === 'failed-funds-returned') { + add.push(result.carve.note) + } else { + // settled / still-pending / already-spent: the carved note is gone or + // locked. When it was one of the wallet's own bearers (an exact-match + // carve), lock that bearer; a freshly carved note is never added - + // it was born spent + const carvedK1 = noteK1(result.carve.note.url) + const existing = carvedK1 + ? bearers.find(b => noteK1(b.url) === carvedK1) + : undefined + if (existing) markSpent.push(existing.id) + } + if (result.rescuedNote) add.push(result.rescuedNote) + return {add, markSpent} +} + +export const handlePayInvoice = async ( + ctx: RequestContext, + params: Record +): Promise => { + const invoice = + typeof params.invoice === 'string' ? params.invoice.trim() : '' + if (!isBolt11Invoice(invoice)) { + return errResult('pay_invoice', 'OTHER', 'Missing or invalid bolt11 invoice.') + } + // the melt must match the invoice's amount exactly, so the amount is + // read from the invoice itself - never trusted from the request + const amountMsat = decodeBolt11AmountMsat(invoice) + if (amountMsat === null || amountMsat <= 0) { + return errResult( + 'pay_invoice', + 'OTHER', + 'Could not read this invoice\'s amount - amount-less invoices are not supported.' + ) + } + if (params.amount !== undefined && params.amount !== amountMsat) { + return errResult( + 'pay_invoice', + 'OTHER', + 'The request\'s amount does not match the invoice.' + ) + } + if ( + amountMsat > budgetRemainingMsat(ctx.connection().record, Date.now()) + ) { + return errResult( + 'pay_invoice', + 'QUOTA_EXCEEDED', + 'This payment exceeds the connection\'s budget.' + ) + } + const bearers = ctx.deps.getBearers() + let result: PayResult + try { + result = await payWithBearers(bearers, invoice, { + poll: ctx.deps.poll ?? {}, + kit: ctx.deps.kit ?? {} + }) + } catch (err) { + if (err instanceof UncertainOutcomeError) { + // the carve's answer was lost and the probe couldn't tell: the + // possible outputs carry fresh secrets that may be the only money + // left - tracked unverified, never dropped + ctx.deps.applyChangeset( + {add: err.possibleOutputs, markSpent: []}, + ctx.connection(), + 'pay_invoice' + ) + return errResult( + 'pay_invoice', + 'INTERNAL', + 'The payment preparation could not be confirmed; possible new notes were stored unverified.' + ) + } + const message = err instanceof Error ? err.message : String(err) + return errResult( + 'pay_invoice', + /enough/i.test(message) ? 'INSUFFICIENT_BALANCE' : 'INTERNAL', + message + ) + } + const spendRecorded = (): void => { + ctx.updateRecord(recordSpend(ctx.connection().record, amountMsat, Date.now())) + } + switch (result.outcome) { + case 'settled': { + spendRecorded() + ctx.deps.applyChangeset(payChangeset(bearers, result), ctx.connection(), 'pay_invoice') + // the receipt NIP-47 clients expect: the melt's own payment + // preimage, re-read from the settle proof. A mint that reveals + // none yields an empty preimage rather than a fabricated one. + let preimage = '' + if (result.verifyUrl) { + try { + const proof = await fetchInvoiceVerification( + result.verifyUrl, + ctx.deps.kit ?? {} + ) + preimage = proof.preimage ?? '' + } catch { + // the settle proof was already polled inside payWithBearers; + // a failed re-read must not flip the outcome + } + } + return okResult('pay_invoice', {preimage}) + } + case 'failed-funds-returned': + ctx.deps.applyChangeset(payChangeset(bearers, result), ctx.connection(), 'pay_invoice') + return errResult( + 'pay_invoice', + 'PAYMENT_FAILED', + 'The payment failed; the funds are back in the wallet.' + ) + case 'note-already-spent': + ctx.deps.applyChangeset(payChangeset(bearers, result), ctx.connection(), 'pay_invoice') + return errResult( + 'pay_invoice', + 'PAYMENT_FAILED', + 'The note backing this payment was already spent; nothing was paid.' + ) + case 'unknown-still-pending': + spendRecorded() + ctx.deps.applyChangeset(payChangeset(bearers, result), ctx.connection(), 'pay_invoice') + return errResult( + 'pay_invoice', + 'OTHER', + 'The payment is still in flight; the note stays locked until it reconciles.' + ) + } +} diff --git a/src/lnurlcash/nwc/protocol.ts b/src/lnurlcash/nwc/protocol.ts new file mode 100644 index 0000000..60e4f7e --- /dev/null +++ b/src/lnurlcash/nwc/protocol.ts @@ -0,0 +1,241 @@ +// The pure half of the NIP-47 wallet service: the request/response event +// codec - validate, decrypt, build, encrypt - with no relay I/O and no +// wallet logic (dispatch lives in service.ts). +// +// Wire shape (NIP-47): +// - the client sends kind 23194, tagged ['p', wallet-service pubkey], its +// content a JSON {method, params} encrypted to the wallet service with +// NIP-44 v2 (tag ['encryption', 'nip44_v2']) or legacy NIP-04 (tag +// ['encryption', 'nip04'], or NO encryption tag at all) +// - the wallet answers with kind 23195, tagged ['p', client pubkey] and +// ['e', request id], its content a JSON {result_type, error, result} +// encrypted back with the SAME scheme the request used +// - a kind 13194 replaceable info event advertises the supported methods +// and encryption schemes + +import type {Event as NostrEvent} from 'nostr-tools/core' +import {finalizeEvent, verifyEvent} from 'nostr-tools/pure' +import {encrypt as nip04Encrypt, decrypt as nip04Decrypt} from 'nostr-tools/nip04' +import {v2 as nip44v2} from 'nostr-tools/nip44' + +export type {NostrEvent} + +export const NWC_REQUEST_KIND = 23194 +export const NWC_RESPONSE_KIND = 23195 +export const NWC_INFO_KIND = 13194 + +export const NWC_METHODS = [ + 'get_info', + 'get_balance', + 'make_invoice', + 'pay_invoice', + 'lookup_invoice' +] as const + +export type NwcMethod = (typeof NWC_METHODS)[number] + +export type NwcErrorCode = + | 'RATE_LIMITED' + | 'NOT_IMPLEMENTED' + | 'INSUFFICIENT_BALANCE' + | 'QUOTA_EXCEEDED' + | 'RESTRICTED' + | 'UNAUTHORIZED' + | 'INTERNAL' + | 'UNSUPPORTED_ENCRYPTION' + | 'PAYMENT_FAILED' + | 'NOT_FOUND' + | 'OTHER' + +export type NwcRequest = { + method: string + params: Record +} + +export type NwcResponse = { + result_type: string + error: {code: NwcErrorCode; message: string} | null + result: unknown +} + +export const okResult = (method: string, result: unknown): NwcResponse => ({ + result_type: method, + error: null, + result +}) + +export const errResult = ( + method: string, + code: NwcErrorCode, + message: string +): NwcResponse => ({ + result_type: method, + error: {code, message}, + result: null +}) + +// the two encryption schemes this service speaks; the scheme of a request +// decides the scheme of its response (NIP-47: "Encrypted using the scheme +// requested by the client") +export type NwcEncryption = 'nip44_v2' | 'nip04' + +const conversationKey = ( + walletSecretKey: Uint8Array, + clientPubkey: string +): Uint8Array => nip44v2.utils.getConversationKey(walletSecretKey, clientPubkey) + +export const encryptFor = ( + scheme: NwcEncryption, + walletSecretKey: Uint8Array, + clientPubkey: string, + plaintext: string +): string => + scheme === 'nip44_v2' + ? nip44v2.encrypt(plaintext, conversationKey(walletSecretKey, clientPubkey)) + : nip04Encrypt(walletSecretKey, clientPubkey, plaintext) + +const decryptFrom = ( + scheme: NwcEncryption, + walletSecretKey: Uint8Array, + clientPubkey: string, + content: string +): string => + scheme === 'nip44_v2' + ? nip44v2.decrypt(content, conversationKey(walletSecretKey, clientPubkey)) + : nip04Decrypt(walletSecretKey, clientPubkey, content) + +const tagValue = (event: NostrEvent, name: string): string | undefined => + event.tags.find(t => t[0] === name)?.[1] + +// The outcome of validating + decrypting a candidate request event: +// - a request to dispatch (encryption scheme carried so the response can +// mirror it) +// - a respondable failure: the event IS an authorized client's request, +// but its content can't be had or parsed - the client still gets a +// well-formed error answer instead of silence +// - null: not ours to answer at all (wrong kind, wrong author, forged +// signature, expired, undecryptable, or a response event echoed back) - +// dropped silently, exactly what a relay full of strangers' traffic +// demands +export type DecryptedNwcRequest = + | {respond: false; request: NwcRequest; encryption: NwcEncryption} + | {respond: true; response: NwcResponse; encryption: NwcEncryption} + +export const decryptRequest = ( + walletSecretKey: Uint8Array, + walletServicePubkey: string, + clientPubkey: string, + event: NostrEvent, + nowSeconds: number = Math.floor(Date.now() / 1000) +): DecryptedNwcRequest | null => { + if (event.kind !== NWC_REQUEST_KIND) return null + // only the authorized client may talk to this connection, and the + // request must actually be addressed to it + if (event.pubkey !== clientPubkey) return null + if (tagValue(event, 'p') !== walletServicePubkey) return null + if (!verifyEvent(event)) return null + // an expired request is ignored, never answered (NIP-47) + const expiration = tagValue(event, 'expiration') + if (expiration !== undefined && Number(expiration) < nowSeconds) { + return null + } + // encryption negotiation: no tag means legacy NIP-04 (NIP-47) + const advertised = tagValue(event, 'encryption') + let encryption: NwcEncryption + if (advertised === undefined || advertised === 'nip04') { + encryption = 'nip04' + } else if (advertised === 'nip44_v2') { + encryption = 'nip44_v2' + } else { + // the client asked for a scheme we don't speak - answer in the + // legacy default, the one scheme every NIP-47 client must read + return { + respond: true, + encryption: 'nip04', + response: errResult( + '', + 'UNSUPPORTED_ENCRYPTION', + `Unsupported encryption scheme: ${advertised}.` + ) + } + } + let plaintext: string + try { + plaintext = decryptFrom(encryption, walletSecretKey, clientPubkey, event.content) + } catch { + // undecryptable - indistinguishable from relay noise; stay silent + return null + } + let data: unknown + try { + data = JSON.parse(plaintext) + } catch { + return { + respond: true, + encryption, + response: errResult('', 'OTHER', 'The request is not valid JSON.') + } + } + if ( + typeof data !== 'object' || + data === null || + typeof (data as NwcRequest).method !== 'string' || + (data as NwcRequest).method === '' + ) { + return { + respond: true, + encryption, + response: errResult('', 'OTHER', 'The request has no method.') + } + } + const request = data as NwcRequest + const params = + typeof request.params === 'object' && request.params !== null + ? request.params + : {} + return {respond: false, request: {method: request.method, params}, encryption} +} + +// signs the kind-23195 answer to a request, mirroring its encryption +// scheme and referencing it via the 'e' tag +export const buildResponseEvent = ( + walletSecretKey: Uint8Array, + clientPubkey: string, + encryption: NwcEncryption, + requestEventId: string, + response: NwcResponse, + createdAt: number = Math.floor(Date.now() / 1000) +): NostrEvent => + finalizeEvent( + { + kind: NWC_RESPONSE_KIND, + created_at: createdAt, + tags: [ + ['p', clientPubkey], + ['e', requestEventId], + ['encryption', encryption] + ], + content: encryptFor( + encryption, + walletSecretKey, + clientPubkey, + JSON.stringify(response) + ) + }, + walletSecretKey + ) + +// the replaceable info event advertising this service's capabilities +export const buildInfoEvent = ( + walletSecretKey: Uint8Array, + createdAt: number = Math.floor(Date.now() / 1000) +): NostrEvent => + finalizeEvent( + { + kind: NWC_INFO_KIND, + created_at: createdAt, + tags: [['encryption', 'nip44_v2 nip04']], + content: NWC_METHODS.join(' ') + }, + walletSecretKey + ) diff --git a/src/lnurlcash/nwc/service.ts b/src/lnurlcash/nwc/service.ts new file mode 100644 index 0000000..55bca53 --- /dev/null +++ b/src/lnurlcash/nwc/service.ts @@ -0,0 +1,190 @@ +// The NWC service runtime: subscribes each connection's relays for NIP-47 +// requests, routes them onto the method handlers (dispatch.ts), and +// encrypts the answers back. Framework-free; sockets are only ever opened +// by the injected (or lazily-defaulted) transport inside startService. +// +// pay_invoice requests are serialized per connection through a promise +// queue, so the budget check and the spend record can't interleave; +// every other method dispatches concurrently. +// +// Foreground replay safety: requests older than MAX_REQUEST_AGE_SECONDS +// are dropped unanswered - a pay_invoice replayed after an offline +// stretch must never execute (a client that retries gets a fresh, +// answered request; a stale one answered late could double-pay). The +// foreground-only design itself is documented in the nwc.ts façade +// header. + +import type {NwcConnectionRecord} from '../storage/nwcConnections' +import {readNwcConnections} from '../storage/nwcConnections' + +import type {NwcConnectionInfo} from './connection' +import {deriveNwcWalletKey, nwcWalletPubkey} from './connection' +import type {NwcServiceDeps, PendingInvoice, RequestContext} from './context' +import {dispatch} from './dispatch' +import type { + NostrEvent, + NwcEncryption, + NwcRequest, + NwcResponse +} from './protocol' +import { + NWC_REQUEST_KIND, + buildInfoEvent, + buildResponseEvent, + decryptRequest +} from './protocol' +import type {NwcSubscription, NwcTransport} from './transport' +import {defaultNwcTransport} from './transport' + +export type {NwcConnectionInfo} +export type {NwcChangeset, NwcServiceDeps} from './context' + +// requests older than this are dropped unanswered (see the header) +const MAX_REQUEST_AGE_SECONDS = 600 + +type ConnectionRuntime = { + info: NwcConnectionInfo + walletSecret: Uint8Array + // invoices this connection issued, by payment hash - in-memory only: + // pending invoices don't survive a restart (lookup then answers + // NOT_FOUND), same as any foreground-only wallet + invoices: Map + // serializes pay_invoice handling per connection - the budget check and + // the spend record can't interleave + queue: Promise + sub: NwcSubscription +} + +export type NwcService = { + // a snapshot of the served connections at startup; the persisted + // records (storage/nwcConnections) carry the live budget state + connections: NwcConnectionInfo[] + // closes every relay subscription. In-flight handlers still finish - + // their changesets hold money - but no new requests are picked up + stop: () => void +} + +export const startService = async ( + linkingPrivKey: Uint8Array, + deps: NwcServiceDeps, + records: NwcConnectionRecord[] = readNwcConnections() +): Promise => { + const transport = deps.transport ?? (await defaultNwcTransport()) + const nowSeconds = (): number => + deps.nowSeconds?.() ?? Math.floor(Date.now() / 1000) + + const publishResponse = async ( + runtime: ConnectionRuntime, + requestEventId: string, + encryption: NwcEncryption, + response: NwcResponse + ): Promise => { + await transport.publish( + runtime.info.record.relays, + buildResponseEvent( + runtime.walletSecret, + runtime.info.record.clientPubkey, + encryption, + requestEventId, + response, + nowSeconds() + ) + ) + } + + // pay_invoice goes through the connection's queue (budget atomicity); + // everything else dispatches directly + const dispatchSerialized = ( + runtime: ConnectionRuntime, + ctx: RequestContext, + request: NwcRequest + ): Promise => { + if (request.method !== 'pay_invoice') return dispatch(ctx, request) + const run = runtime.queue.then( + () => dispatch(ctx, request), + () => dispatch(ctx, request) + ) + runtime.queue = run.catch(() => undefined) + return run + } + + const handleEvent = async ( + runtime: ConnectionRuntime, + ctx: RequestContext, + event: NostrEvent + ): Promise => { + const at = nowSeconds() + // replay safety (see the header): too-old requests are dropped + if (event.created_at < at - MAX_REQUEST_AGE_SECONDS) return + const decrypted = decryptRequest( + runtime.walletSecret, + runtime.info.walletServicePubkey, + runtime.info.record.clientPubkey, + event, + at + ) + if (decrypted === null) return + if (decrypted.respond) { + await publishResponse(runtime, event.id, decrypted.encryption, decrypted.response) + return + } + const response = await dispatchSerialized(runtime, ctx, decrypted.request) + await publishResponse(runtime, event.id, decrypted.encryption, response) + } + + const runtimes = records.map(record => { + const walletSecret = deriveNwcWalletKey(linkingPrivKey, record.clientPubkey) + const runtime: ConnectionRuntime = { + info: {record, walletServicePubkey: nwcWalletPubkey(walletSecret)}, + walletSecret, + invoices: new Map(), + queue: Promise.resolve(), + // replaced below, immediately - the field exists because the + // subscription callback closes over the runtime + sub: {close: () => undefined} + } + const ctx: RequestContext = { + deps, + connection: () => runtime.info, + updateRecord: updated => { + runtime.info = {...runtime.info, record: updated} + }, + invoices: runtime.invoices, + nowSeconds + } + runtime.sub = transport.subscribe( + record.relays, + { + kinds: [NWC_REQUEST_KIND], + '#p': [runtime.info.walletServicePubkey], + since: nowSeconds() + }, + event => { + void handleEvent(runtime, ctx, event).catch(err => + deps.onError?.(err, runtime.info) + ) + } + ) + return runtime + }) + + // info events: best-effort - a rejected publish must not sink startup; + // the client learns capabilities from its first error-free exchange too + for (const runtime of runtimes) { + try { + await transport.publish( + runtime.info.record.relays, + buildInfoEvent(runtime.walletSecret, nowSeconds()) + ) + } catch (err) { + deps.onError?.(err, runtime.info) + } + } + + return { + connections: runtimes.map(r => r.info), + stop: () => { + for (const runtime of runtimes) runtime.sub.close() + } + } +} diff --git a/src/lnurlcash/nwc/transport.ts b/src/lnurlcash/nwc/transport.ts new file mode 100644 index 0000000..b218686 --- /dev/null +++ b/src/lnurlcash/nwc/transport.ts @@ -0,0 +1,40 @@ +// The relay-facing transport for the NWC service, kept injectable so +// tests never touch a network. Same pattern as nostr/transport.ts: the +// default (nostr-tools' SimplePool) is imported lazily, so merely +// importing the service module never opens (or even references) a +// WebSocket. + +import type {Filter as NostrFilter} from 'nostr-tools/filter' + +import type {NostrEvent} from './protocol' + +export type {NostrFilter} + +export type NwcSubscription = { + close: () => void +} + +export type NwcTransport = { + publish: (relays: string[], event: NostrEvent) => Promise + subscribe: ( + relays: string[], + filter: NostrFilter, + onEvent: (event: NostrEvent) => void + ) => NwcSubscription +} + +export const defaultNwcTransport = async (): Promise => { + const {SimplePool} = await import('nostr-tools/pool') + const pool = new SimplePool() + return { + publish: async (relays, event) => { + const results = await Promise.allSettled(pool.publish(relays, event)) + // one honest relay accepting is enough - same rule as the backup + if (!results.some(r => r.status === 'fulfilled')) { + throw new Error('No relay accepted the event.') + } + }, + subscribe: (relays, filter, onEvent) => + pool.subscribeMany(relays, filter, {onevent: onEvent}) + } +} diff --git a/src/lnurlcash/storage/nwcConnections.ts b/src/lnurlcash/storage/nwcConnections.ts new file mode 100644 index 0000000..582ac5b --- /dev/null +++ b/src/lnurlcash/storage/nwcConnections.ts @@ -0,0 +1,109 @@ +// 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. + +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 +} + +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) + clientPubkey: string + relays: string[] + budget: NwcBudget + spent: NwcBudgetSpend + createdAt: number +} + +const NWC_CONNECTIONS_STORAGE_KEY = 'sattle_nwc_connections' + +const HEX_64 = /^[0-9a-f]{64}$/i + +// 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 + const budget = r.budget as Record | null + const spent = r.spent as Record | 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' + ) +} + +// malformed entries are dropped, not thrown on - one corrupted record must +// not take the remaining connections down with it +export const readNwcConnections = (): NwcConnectionRecord[] => { + const raw = localStorage.getItem(NWC_CONNECTIONS_STORAGE_KEY) + if (!raw) return [] + try { + const parsed: unknown = JSON.parse(raw) + return Array.isArray(parsed) + ? parsed.filter(isValidNwcConnectionRecord) + : [] + } catch { + return [] + } +} + +export const writeNwcConnections = ( + records: NwcConnectionRecord[] +): void => { + localStorage.setItem(NWC_CONNECTIONS_STORAGE_KEY, JSON.stringify(records)) +} + +// 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 +): NwcConnectionRecord => { + const records = readNwcConnections() + const index = records.findIndex(r => r.clientPubkey === record.clientPubkey) + if (index >= 0) records[index] = record + else records.push(record) + writeNwcConnections(records) + return record +} + +export const removeNwcConnection = (clientPubkey: string): void => { + writeNwcConnections( + readNwcConnections().filter(r => r.clientPubkey !== clientPubkey) + ) +}