diff --git a/README.md b/README.md index c89a536..fad6be9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients. -Only depends on _@scure_ and _@noble_ packages. +Only depends on _@scure_ and _@noble_ packages and the _light-bolt11-decoder_(that only relies on the _@scure/base_ package). This package is only providing lower-level functionality. If you want an easy-to-use fully-fledged solution that abstracts the hard parts of Nostr and makes decisions on your behalf, take a look at [NDK](https://github.com/nostr-dev-kit/ndk) and [@snort/system](https://www.npmjs.com/package/@snort/system). diff --git a/nip57.test.ts b/nip57.test.ts index a5608ac..00ce6b9 100644 --- a/nip57.test.ts +++ b/nip57.test.ts @@ -1,7 +1,14 @@ import { describe, test, expect, mock } from 'bun:test' import { finalizeEvent } from './pure.ts' import { getPublicKey, generateSecretKey } from './pure.ts' -import { getZapEndpoint, makeZapReceipt, makeZapRequest, useFetchImplementation, validateZapRequest } from './nip57.ts' +import { + getZapEndpoint, + makeZapReceipt, + makeZapRequest, + useFetchImplementation, + validateZapReceipt, + validateZapRequest, +} from './nip57.ts' import { buildEvent } from './test-helpers.ts' describe('getZapEndpoint', () => { @@ -317,3 +324,107 @@ describe('makeZapReceipt', () => { expect(JSON.stringify(result.tags)).not.toContain('preimage') }) }) + +describe('validateZapReceipt', () => { + test("returns an error message if zap receipt's pubkey does not match prodiver's nostrPubkey", async () => { + const fetchImplementation = mock(() => + Promise.resolve({ + json: () => ({ + allowsNostr: true, + nostrPubkey: 'pubkey2', + callback: 'callback', + }), + }), + ) + useFetchImplementation(fetchImplementation) + + const metadata = buildEvent({ + kind: 0, + content: '{"lud06": "lnurl1dp68gurn8ghj7er0d4skjm309emk2mrv944kummhdchkcmn4wfk8qtmwv9kk2vkepaf"}', + }) + + const privateKey = generateSecretKey() + const zapRequest = finalizeEvent( + { + kind: 9734, + created_at: Date.now() / 1000, + content: 'content', + tags: [ + ['p', '47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4'], + ['amount', '200000'], + ['lnurl', 'lnurl1dp68gurn8ghj7er0d4skjm309emk2mrv944kummhdchkcmn4wfk8qtmwv9kk2vkepaf'], + ['relays', 'relay1', 'relay2'], + ], + }, + privateKey, + ) + const validZapReceipt = buildEvent({ + kind: 9735, + pubkey: 'pubkey', + tags: [ + ['p', '47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4'], + ['e', '072b76e219cd616709a9731104937d281b93283702b019f048603654d876a06f'], + ['P', '0c52bd52cc24adfef62f7fb9c641056a762fc1ec3e5be0bde4b79f3956e51665'], + [ + 'bolt11', + 'lnbc2u1pnx2pwspp5pzw8yj0ummke3g6hxufa8v84emdvj0ry8ds6wy98ch2cpdq89hgshp5usd6se5h59vuscladwuhgm8uxdp54vwyu6we8dp9hfkc8fqtpfzqcqzzsxqyz5vqsp5t8g4wst407pkuuwdy3f6yhtq49k5ewfdxxphfjy35edg925lfzzq9qyyssqs9x5g5pflvg3zc3ueygm5fmxxgqdw7lv0hkyjktr0dav3jurfkcnhpkptzhrywp7an0e825wv3w4znpmm0khdptq408nw6x3gusr3wspdasmay', + ], + ['preimage', '6bfacb20e12d6e4ea068ad39ed48392cd9bd7535e0d1bc185319494db0202709'], + ['description', JSON.stringify(zapRequest)], + ], + }) + expect(await validateZapReceipt(validZapReceipt, metadata)).toBe( + "Zap receipt's pubkey does not match lnurl provider's nostrPubkey.", + ) + }) + + test('returns null for a valid Zap receipt', async () => { + const fetchImplementation = mock(() => + Promise.resolve({ + json: () => ({ + allowsNostr: true, + nostrPubkey: 'pubkey', + callback: 'callback', + }), + }), + ) + useFetchImplementation(fetchImplementation) + + const metadata = buildEvent({ + kind: 0, + content: '{"lud06": "lnurl1dp68gurn8ghj7er0d4skjm309emk2mrv944kummhdchkcmn4wfk8qtmwv9kk2vkepaf"}', + }) + + const privateKey = generateSecretKey() + const zapRequest = finalizeEvent( + { + kind: 9734, + created_at: Date.now() / 1000, + content: 'content', + tags: [ + ['p', '47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4'], + ['amount', '200000'], + ['lnurl', 'lnurl1dp68gurn8ghj7er0d4skjm309emk2mrv944kummhdchkcmn4wfk8qtmwv9kk2vkepaf'], + ['relays', 'relay1', 'relay2'], + ], + }, + privateKey, + ) + const validZapReceipt = buildEvent({ + kind: 9735, + pubkey: 'pubkey', + tags: [ + ['p', '47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4'], + ['e', '072b76e219cd616709a9731104937d281b93283702b019f048603654d876a06f'], + ['P', '0c52bd52cc24adfef62f7fb9c641056a762fc1ec3e5be0bde4b79f3956e51665'], + [ + 'bolt11', + 'lnbc2u1pnx2pwspp5pzw8yj0ummke3g6hxufa8v84emdvj0ry8ds6wy98ch2cpdq89hgshp5usd6se5h59vuscladwuhgm8uxdp54vwyu6we8dp9hfkc8fqtpfzqcqzzsxqyz5vqsp5t8g4wst407pkuuwdy3f6yhtq49k5ewfdxxphfjy35edg925lfzzq9qyyssqs9x5g5pflvg3zc3ueygm5fmxxgqdw7lv0hkyjktr0dav3jurfkcnhpkptzhrywp7an0e825wv3w4znpmm0khdptq408nw6x3gusr3wspdasmay', + ], + ['preimage', '6bfacb20e12d6e4ea068ad39ed48392cd9bd7535e0d1bc185319494db0202709'], + ['description', JSON.stringify(zapRequest)], + ], + }) + expect(await validateZapReceipt(validZapReceipt, metadata)).toBe(null) + }) +}) diff --git a/nip57.ts b/nip57.ts index a95a52d..4bf337d 100644 --- a/nip57.ts +++ b/nip57.ts @@ -1,4 +1,5 @@ import { bech32 } from '@scure/base' +const bolt11 = require('light-bolt11-decoder') import { validateEvent, verifyEvent, type Event, type EventTemplate } from './pure.ts' import { utf8Decoder } from './utils.ts' @@ -15,29 +16,47 @@ export function useFetchImplementation(fetchImplementation: any) { export async function getZapEndpoint(metadata: Event): Promise { try { + const lnurl = getDecodedLnurl(metadata) + + let res = await _fetch(lnurl) + let body = await res.json() + + if (body.allowsNostr && body.nostrPubkey) { + return body.callback + } + } catch (err) { + /*-*/ + } + + return null +} + +function getDecodedLnurl(metadata: Event | null, lnurlEncoded = ''): null | string { + try { + if (lnurlEncoded !== '') { + let { words } = bech32.decode(lnurlEncoded, 1000) + let data = bech32.fromWords(words) + const lnurl = utf8Decoder.decode(data) + return lnurl + } + + if (metadata === null) return null + let lnurl: string = '' let { lud06, lud16 } = JSON.parse(metadata.content) if (lud06) { let { words } = bech32.decode(lud06, 1000) let data = bech32.fromWords(words) lnurl = utf8Decoder.decode(data) + return lnurl } else if (lud16) { let [name, domain] = lud16.split('@') lnurl = new URL(`/.well-known/lnurlp/${name}`, `https://${domain}`).toString() - } else { - return null - } - - let res = await _fetch(lnurl) - let body = await res.json() - - if (body.allowsNostr && body.nostrPubkey) { - return body.callback + return lnurl } } catch (err) { - /*-*/ + console.log(err) } - return null } @@ -128,3 +147,50 @@ export function makeZapReceipt({ return zap } + +export async function validateZapReceipt( + zapReceipt: Event, + zapReceiptRecipientMetadata: Event, +): Promise { + if (zapReceipt?.kind !== 9735) return 'Zap receipt has the wrong kind number.' + + try { + const decodedLnurl = getDecodedLnurl(zapReceiptRecipientMetadata) + const res = await _fetch(decodedLnurl) + const body = await res.json() + + if (!body?.allowsNostr) return 'allowsNostr is not supported' + + if (body?.nostrPubkey !== zapReceipt.pubkey) { + return "Zap receipt's pubkey does not match lnurl provider's nostrPubkey." + } + + const zapRequestErrorMessage = validateZapRequest( + zapReceipt.tags.find(([name]) => name === 'description')?.[1] ?? '', + ) + if (zapRequestErrorMessage !== null) return zapRequestErrorMessage + + const invoice = zapReceipt.tags.find(([name]) => name === 'bolt11')?.[1] + if (invoice) { + const amountBolt11 = (bolt11.decode(invoice).sections as { name: string; value: string }[]).find( + ({ name }) => name === 'amount', + )?.value + + const zapRequest = JSON.parse(zapReceipt.tags.find(([name]) => name === 'description')?.[1]!) as Event + const amountZapRequest = zapRequest.tags.find(([name]) => name === 'amount')?.[1] + + if (amountBolt11 !== amountZapRequest) return 'Zaps amount do not match.' + } + + const zapRequest = JSON.parse(zapReceipt.tags.find(([name]) => name === 'description')?.[1]!) as Event + const zapRequestLnurl = zapRequest.tags.find(([name]) => name === 'lnurl')?.[1] + if (zapRequestLnurl) { + const zapRequestLnurlDecoded = getDecodedLnurl(null, zapRequestLnurl) + if (decodedLnurl !== zapRequestLnurlDecoded) return 'Lnurl does not match' + } + } catch (err) { + console.log(err) + return 'Could not validate zap receipt' + } + return null +} diff --git a/package.json b/package.json index cba80fb..a5f2f08 100644 --- a/package.json +++ b/package.json @@ -221,7 +221,8 @@ "@noble/hashes": "1.3.1", "@scure/base": "1.1.1", "@scure/bip32": "1.3.1", - "@scure/bip39": "1.2.1" + "@scure/bip39": "1.2.1", + "light-bolt11-decoder": "^3.1.1" }, "optionalDependencies": { "nostr-wasm": "v0.1.0"