Skip to content
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')

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