Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Validate zap receipt #412

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
113 changes: 112 additions & 1 deletion nip57.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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)
})
})
88 changes: 77 additions & 11 deletions nip57.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { bech32 } from '@scure/base'
const bolt11 = require('light-bolt11-decoder')
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this a require instead of an ES import?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because there's no types for it.


import { validateEvent, verifyEvent, type Event, type EventTemplate } from './pure.ts'
import { utf8Decoder } from './utils.ts'
Expand All @@ -15,29 +16,47 @@ export function useFetchImplementation(fetchImplementation: any) {

export async function getZapEndpoint(metadata: Event): Promise<null | string> {
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
}

Expand Down Expand Up @@ -128,3 +147,50 @@ export function makeZapReceipt({

return zap
}

export async function validateZapReceipt(
zapReceipt: Event,
zapReceiptRecipientMetadata: Event,
): Promise<string | null> {
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
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down