From 9dd401a1b2b44b63e5f36c17ab97c082e2a2e4fb Mon Sep 17 00:00:00 2001 From: DocNR Date: Sun, 14 Jun 2026 07:29:08 -0400 Subject: [PATCH] feat(zap): validate zap receipts before counting Verify each kind-9735 zap receipt before it inflates stats, notifications, or the supporters list. Two NIP-57 checks (Appendix F / E): 1. Issuer: the receipt must be signed by the nostrPubkey advertised by the recipient's LNURL pay endpoint. Lenient when the endpoint or its nostrPubkey can't be resolved (can't prove forgery). 2. Preimage: when present, sha256(preimage) must equal the bolt11 payment hash. lightning.validateZapReceipt() caches the per-recipient nostrPubkey lookup (DataLoader). Wired into stuff-stats (addZapByEvent now async-validates, then notifies itself) and ZapNotification (gates render on validation). The supporters query drops its alby-author hardcode in favor of real validation. Ported from upstream Jumble b2f4714c, adapted to jank's profileFetcher / eventCache / relayListService. Adds a unit test (no upstream equivalent). Live-verified: Wallet of Satoshi, Rizful, and yakihonne all sign with their advertised nostrPubkey (issuer == endpoint nostrPubkey), so legit zaps pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../NotificationItem/ZapNotification.tsx | 16 ++- .../lightning.validate-zap-receipt.spec.ts | 127 ++++++++++++++++++ src/services/lightning.service.ts | 60 ++++++++- src/services/stuff-stats.service.ts | 20 ++- 4 files changed, 207 insertions(+), 16 deletions(-) create mode 100644 src/services/__tests__/lightning.validate-zap-receipt.spec.ts diff --git a/src/components/NotificationList/NotificationItem/ZapNotification.tsx b/src/components/NotificationList/NotificationItem/ZapNotification.tsx index 01e5dff..024d9fb 100644 --- a/src/components/NotificationList/NotificationItem/ZapNotification.tsx +++ b/src/components/NotificationList/NotificationItem/ZapNotification.tsx @@ -1,9 +1,10 @@ import { useFetchEvent } from '@/hooks' import { getZapInfoFromEvent } from '@/lib/event-metadata' import { formatAmount } from '@/lib/lightning' +import lightning from '@/services/lightning.service' import { Zap } from 'lucide-react' import { Event } from 'nostr-tools' -import { useMemo } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Notification from './Notification' @@ -25,8 +26,19 @@ export function ZapNotification({ [notification] ) const { event } = useFetchEvent(eventId) + const [valid, setValid] = useState(null) - if (!senderPubkey || !amount) return null + useEffect(() => { + let cancelled = false + lightning.validateZapReceipt(notification).then((result) => { + if (!cancelled) setValid(result) + }) + return () => { + cancelled = true + } + }, [notification]) + + if (!senderPubkey || !amount || !valid) return null return ( ({ + fetchProfile: vi.fn() +})) + +vi.mock('@/services/profile-fetcher.service', () => ({ + default: { fetchProfile: mocks.fetchProfile } +})) + +vi.mock('@getalby/bitcoin-connect-react', () => ({ + init: vi.fn(), + launchPaymentModal: vi.fn() +})) + +import lightning from '../lightning.service' + +// A real, parseable bolt11 (from the NIP-57 example zap receipt). +const BOLT11 = + 'lnbc10u1p3unwfusp5t9r3yymhpfqculx78u027lxspgxcr2n2987mx2j55nnfs95nxnzqpp5jmrh92pfld78spqs78v9euf2385t83uvpwk9ldrlvf6ch7tpascqhp5zvkrmemgth3tufcvflmzjzfvjt023nazlhljz2n9hattj4f8jq8qxqyjw5qcqpjrzjqtc4fc44feggv7065fqe5m4ytjarg3repr5j9el35xhmtfexc42yczarjuqqfzqqqqqqqqlgqqqqqqgq9q9qxpqysgq079nkq507a5tw7xgttmj4u990j7wfggtrasah5gd4ywfr2pjcn29383tphp4t48gquelz9z78p4cq7ml3nrrphw5w6eckhjwmhezhnqpy6gyf0' + +const ISSUER_PK = 'aaaa000000000000000000000000000000000000000000000000000000000001' +const SENDER_PK = 'bbbb000000000000000000000000000000000000000000000000000000000002' + +// Each test uses a distinct recipient pubkey because the service caches LNURL +// nostrPubkey lookups per recipient (DataLoader) across the singleton's life. +function receipt({ + recipient, + issuer = ISSUER_PK, + preimage +}: { + recipient: string + issuer?: string + preimage?: string +}) { + const tags: string[][] = [ + ['p', recipient], + ['P', SENDER_PK], + ['e', '3624762a1274dd9636e0c552b53086d70bc88c165bc4dc0f9e836a1eaf86c3b8'], + ['bolt11', BOLT11] + ] + if (preimage) tags.push(['preimage', preimage]) + return { + id: 'id', + kind: 9735, + pubkey: issuer, + created_at: 1674164545, + content: '', + sig: '', + tags + } +} + +function stubEndpoint(nostrPubkey?: string) { + mocks.fetchProfile.mockResolvedValue({ lightningAddress: 'sats@example.com' }) + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + json: async () => ({ allowsNostr: true, callback: 'https://example.com/cb', nostrPubkey }) + })) + ) +} + +describe('lightning.validateZapReceipt', () => { + beforeEach(() => { + mocks.fetchProfile.mockReset() + }) + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('accepts a receipt signed by the endpoint nostrPubkey (NIP-57 Appendix F)', async () => { + stubEndpoint(ISSUER_PK) + const ok = await lightning.validateZapReceipt( + receipt({ recipient: 'r1'.padEnd(64, '0'), issuer: ISSUER_PK }) as never + ) + expect(ok).toBe(true) + }) + + it('rejects a forged receipt signed by a different pubkey', async () => { + stubEndpoint(ISSUER_PK) + const ok = await lightning.validateZapReceipt( + receipt({ recipient: 'r2'.padEnd(64, '0'), issuer: SENDER_PK }) as never + ) + expect(ok).toBe(false) + }) + + it('accepts leniently when the endpoint has no nostrPubkey to prove forgery', async () => { + stubEndpoint(undefined) + const ok = await lightning.validateZapReceipt( + receipt({ recipient: 'r3'.padEnd(64, '0'), issuer: SENDER_PK }) as never + ) + expect(ok).toBe(true) + }) + + it('rejects when the preimage does not match the bolt11 payment hash', async () => { + stubEndpoint(ISSUER_PK) + const ok = await lightning.validateZapReceipt( + receipt({ + recipient: 'r4'.padEnd(64, '0'), + issuer: ISSUER_PK, + preimage: '00'.repeat(32) + }) as never + ) + expect(ok).toBe(false) + }) + + it('rejects a receipt with no bolt11 invoice', async () => { + stubEndpoint(ISSUER_PK) + const ok = await lightning.validateZapReceipt({ + id: 'id', + kind: 9735, + pubkey: ISSUER_PK, + created_at: 1674164545, + content: '', + sig: '', + tags: [['p', 'r5'.padEnd(64, '0')]] + } as never) + expect(ok).toBe(false) + }) +}) diff --git a/src/services/lightning.service.ts b/src/services/lightning.service.ts index c242409..7c81d92 100644 --- a/src/services/lightning.service.ts +++ b/src/services/lightning.service.ts @@ -7,6 +7,7 @@ import { init, launchPaymentModal } from '@getalby/bitcoin-connect-react' import { Invoice } from '@getalby/lightning-tools' import { bech32 } from '@scure/base' import { WebLNProvider } from '@webbtc/webln-types' +import DataLoader from 'dataloader' import dayjs from 'dayjs' import { Filter, kinds, NostrEvent } from 'nostr-tools' import { SubCloser } from 'nostr-tools/abstract-pool' @@ -25,6 +26,15 @@ class LightningService { static instance: LightningService provider: WebLNProvider | null = null private recentSupportersCache: TRecentSupporter[] | null = null + private nostrPubkeyLoader = new DataLoader( + async (recipientPubkeys) => { + const results = await Promise.allSettled( + recipientPubkeys.map((pubkey) => this.fetchRecipientNostrPubkey(pubkey)) + ) + return results.map((res) => (res.status === 'fulfilled' ? res.value : null)) + }, + { maxBatchSize: 1 } + ) constructor() { if (!LightningService.instance) { @@ -212,14 +222,15 @@ class LightningService { } const relayList = await relayListService.fetchRelayList(CODY_PUBKEY) const events = await eventCache.fetchEvents(relayList.read.slice(0, 4), { - authors: ['79f00d3f5a19ec806189fcab03c1be4ff81d18ee4f653c88fac41fe03570f432'], // alby kinds: [kinds.Zap], '#p': OFFICIAL_PUBKEYS, since: dayjs().subtract(1, 'month').unix() }) events.sort((a, b) => b.created_at - a.created_at) + const validations = await Promise.all(events.map((event) => this.validateZapReceipt(event))) const map = new Map() - events.forEach((event) => { + events.forEach((event, index) => { + if (!validations[index]) return const info = getZapInfoFromEvent(event) if (!info || !info.senderPubkey || OFFICIAL_PUBKEYS.includes(info.senderPubkey)) return @@ -238,9 +249,51 @@ class LightningService { return this.recentSupportersCache } + /** + * Validates a zap receipt (kind 9735) to ensure it represents a real payment. + * + * Two checks are performed (NIP-57 Appendix F / E): + * 1. Issuer check: the receipt must be signed by the `nostrPubkey` advertised + * by the recipient's LNURL pay endpoint. This rejects forged receipts. When + * the endpoint or its `nostrPubkey` can't be resolved, the receipt is + * accepted leniently — we can't prove it forged. + * 2. Preimage check: when the receipt carries a `preimage` tag, `sha256(preimage)` + * must equal the bolt11 payment hash. The tag is optional in NIP-57, so its + * absence is not treated as invalid. + * + * Note: NIP-57 also asks that the bolt11 invoice amount equal the zap request's + * `amount` tag. That cross-check is not performed here (matches upstream); the + * issuer check is the primary forgery defense. + */ + async validateZapReceipt(receipt: NostrEvent): Promise { + const info = getZapInfoFromEvent(receipt) + if (!info || !info.recipientPubkey || !info.invoice) return false + + if (info.preimage) { + try { + const invoice = new Invoice({ pr: info.invoice }) + if (!invoice.validatePreimage(info.preimage)) return false + } catch { + return false + } + } + + const nostrPubkey = await this.nostrPubkeyLoader.load(info.recipientPubkey) + if (!nostrPubkey) return true + return receipt.pubkey === nostrPubkey + } + + private async fetchRecipientNostrPubkey(recipientPubkey: string): Promise { + const profile = await profileFetcher.fetchProfile(recipientPubkey) + if (!profile) return null + const endpoint = await this.getZapEndpoint(profile) + return endpoint?.nostrPubkey ?? null + } + private async getZapEndpoint(profile: TProfile): Promise { try { let lnurl: string = '' @@ -267,7 +320,8 @@ class LightningService { if (body.allowsNostr !== false && body.callback) { return { callback: body.callback, - lnurl + lnurl, + nostrPubkey: typeof body.nostrPubkey === 'string' ? body.nostrPubkey : undefined } } } catch (err) { diff --git a/src/services/stuff-stats.service.ts b/src/services/stuff-stats.service.ts index 4e21d20..acf830c 100644 --- a/src/services/stuff-stats.service.ts +++ b/src/services/stuff-stats.service.ts @@ -9,6 +9,7 @@ import { getZapInfoFromEvent } from '@/lib/event-metadata' import { getDefaultRelayUrls } from '@/lib/relay' import { getEmojiInfosFromEmojiTags, tagNameEquals } from '@/lib/tag' import eventCache from '@/services/caches/event-cache.service' +import lightning from '@/services/lightning.service' import profileFetcher from '@/services/profile-fetcher.service' import relayListService from '@/services/fetchers/relay-list.service' import { TEmoji } from '@/types' @@ -243,7 +244,9 @@ class StuffStatsService { } else if (evt.kind === kinds.Repost || evt.kind === kinds.GenericRepost) { targetKey = this.addRepostByEvent(evt) } else if (evt.kind === kinds.Zap) { - targetKey = this.addZapByEvent(evt) + // Zap receipts need an async issuer check before counting; the method + // validates the receipt and notifies subscribers itself. + this.addZapByEvent(evt) } if (targetKey) { targetKeySet.add(targetKey) @@ -353,21 +356,16 @@ class StuffStatsService { return targetEventKey } - private addZapByEvent(evt: Event) { + private async addZapByEvent(evt: Event) { const info = getZapInfoFromEvent(evt) if (!info) return const { originalEventId, senderPubkey, invoice, amount, comment } = info if (!originalEventId || !senderPubkey || amount <= 0) return - return this.addZap( - senderPubkey, - originalEventId, - invoice, - amount, - comment, - evt.created_at, - false - ) + const valid = await lightning.validateZapReceipt(evt) + if (!valid) return + + this.addZap(senderPubkey, originalEventId, invoice, amount, comment, evt.created_at) } }