Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -25,8 +26,19 @@ export function ZapNotification({
[notification]
)
const { event } = useFetchEvent(eventId)
const [valid, setValid] = useState<boolean | null>(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 (
<Notification
Expand Down
127 changes: 127 additions & 0 deletions src/services/__tests__/lightning.validate-zap-receipt.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

// validateZapReceipt resolves the recipient's LNURL `nostrPubkey` via
// profileFetcher.fetchProfile + a fetch() to the LNURL endpoint, then asserts
// the receipt was signed by that pubkey (NIP-57 Appendix F). Mock the profile
// fetcher and stub fetch so the test drives the endpoint's advertised
// nostrPubkey, and mock bitcoin-connect's init() (called in the service
// constructor) so importing the singleton is clean in the node test env.
const mocks = vi.hoisted(() => ({
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)
})
})
60 changes: 57 additions & 3 deletions src/services/lightning.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -25,6 +26,15 @@ class LightningService {
static instance: LightningService
provider: WebLNProvider | null = null
private recentSupportersCache: TRecentSupporter[] | null = null
private nostrPubkeyLoader = new DataLoader<string, string | null>(
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) {
Expand Down Expand Up @@ -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<string, { pubkey: string; amount: number; comment?: string }>()
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

Expand All @@ -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<boolean> {
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<string | null> {
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<null | {
callback: string
lnurl: string
nostrPubkey?: string
}> {
try {
let lnurl: string = ''
Expand All @@ -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) {
Expand Down
20 changes: 9 additions & 11 deletions src/services/stuff-stats.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}

Expand Down
Loading