Compare commits

..

25 Commits

Author SHA1 Message Date
fiatjaf
51b8f42529 bump to v1.8.1 2023-03-27 10:41:10 -03:00
fiatjaf
24d885aaeb Revert "earlier .add() on pool _knownIds."
This reverts commit 687f387385.
2023-03-27 10:27:42 -03:00
Sepehr Safari
74c77a2e9f add 17 test cases for nip57 (#166) 2023-03-26 19:38:33 -03:00
fiatjaf
ce73b96565 bump to v1.8.0 2023-03-26 09:45:03 -03:00
fiatjaf
8818e4f88a add parseReferences() for NIP-10 and NIP-27. 2023-03-26 09:44:33 -03:00
fiatjaf
5a63c75f24 nevent author is not mandatory. 2023-03-26 09:35:42 -03:00
Sepehr Safari
60e01a9006 edit filter.test.js and add more test cases (#165)
* add more test cases for event file

* add another test case for event file

* edit filter.test.js and add more test cases
2023-03-26 07:13:02 -03:00
fiatjaf
687f387385 earlier .add() on pool _knownIds. 2023-03-26 07:10:41 -03:00
fiatjaf
6d116a2f7f add author on nevent TLV. 2023-03-26 07:10:13 -03:00
Sepehr Safari
51c3aec788 add another test case for event file 2023-03-23 17:56:11 -03:00
Sepehr Safari
613b2c177f add more test cases for event file 2023-03-23 17:56:11 -03:00
fiatjaf
24f5068fdb bump to v1.7.5 2023-03-19 09:09:19 -03:00
fiatjaf
5733f9c4e4 reject promise on WebSocket initiation failure. 2023-03-19 09:07:16 -03:00
BilligsterUser
6b73bbf8a3 Type sub event handler (#156)
* RelayEvent allow Promises

* type Sub EventHandler

* Update relay.ts
2023-03-12 08:51:38 -03:00
BilligsterUser
d244b62c7a type Relay EventHandler (#121)
* type Relay EventHandler

* Update relay.ts
2023-03-11 15:24:52 -03:00
BilligsterUser
b00af9a30a call ensurerelay() before calling pool.publish()
fixes #153
2023-03-11 14:22:11 -03:00
BilligsterUser
be7c981c14 NIP-39: validate github 2023-03-11 08:33:36 -03:00
BilligsterUser
5539e5cf89 Pool: Sub use provided alreadyHaveEvent Fn 2023-03-06 13:27:24 -03:00
Egge
73decbc8e0 added connect to ensureRelay if status != 1 2023-03-06 11:13:08 -03:00
BilligsterUser
b3d95cecdd add search field to filter (NIP-50) 2023-03-05 08:08:36 -03:00
ramigs
82228036ef close relay's websocket only if it's in state OPEN 2023-03-04 12:57:50 -03:00
fiatjaf
01435ab9f5 test decode nip19 with relays.
closes https://github.com/nbd-wtf/nostr-tools/issues/147
2023-03-02 21:32:53 -03:00
fiatjaf
63cbc4133a nip05 domain must have a dot. 2023-03-02 21:32:53 -03:00
BilligsterUser
049f183d27 update actions/checkout to v3
fixes ci warning

Node.js 12 actions are deprecated. Please update the following actions to use Node.js 16: actions/checkout@v2. For more information see: https://github.blog/changelog/2022-09-22-github-actions-all-actions-will-begin-running-on-node16-instead-of-node12/.
2023-03-02 09:38:10 -03:00
BilligsterUser
f9e3119ab4 package.json add License field 2023-03-02 09:34:54 -03:00
17 changed files with 1170 additions and 125 deletions

View File

@@ -9,7 +9,7 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18

View File

@@ -170,6 +170,26 @@ let relaysForEvent = pool.seenOn(
// relaysForEvent will be an array of URLs from relays a given event was seen on
```
### Parsing references (mentions) from a content using NIP-10 and NIP-27
```js
import {parseReferences} from 'nostr-tools'
let references = parseReferences(event)
let simpleAugmentedContent = event.content
for (let i = 0; i < references.length; i++) {
let {text, profile, event, address} = references[i]
let augmentedReference = profile
? `<strong>@${profilesCache[profile.pubkey].name}</strong>`
: event
? `<em>${eventsCache[event.id].content.slice(0, 5)}</em>`
: address
? `<a href="${text}">[link]</a>`
: text
simpleAugmentedContent.replaceAll(text, augmentedReference)
}
```
### Querying profile data from a NIP-05 address
```js

View File

@@ -1,48 +1,340 @@
/* eslint-env jest */
const {
getBlankEvent,
finishEvent,
serializeEvent,
getEventHash,
validateEvent,
verifySignature,
signEvent,
getPublicKey
getPublicKey,
Kind
} = require('./lib/nostr.cjs')
const event = {
id: 'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027',
kind: 1,
pubkey: '22a12a128a3be27cd7fb250cbe796e692896398dc1440ae3fa567812c8107c1c',
created_at: 1670869179,
content:
'NOSTR "WINE-ACCOUNT" WITH HARVEST DATE STAMPED\n\n\n"The older the wine, the greater its reputation"\n\n\n22a12a128a3be27cd7fb250cbe796e692896398dc1440ae3fa567812c8107c1c\n\n\nNWA 2022-12-12\nAA',
tags: [['client', 'astral']],
sig: 'f110e4fdf67835fb07abc72469933c40bdc7334615610cade9554bf00945a1cebf84f8d079ec325d26fefd76fe51cb589bdbe208ac9cdbd63351ddad24a57559'
}
describe('Event', () => {
describe('getBlankEvent', () => {
it('should return a blank event object', () => {
expect(getBlankEvent()).toEqual({
kind: 255,
content: '',
tags: [],
created_at: 0
})
})
})
const unsigned = {
created_at: 1671217411,
kind: 0,
tags: [],
content:
'{"name":"fiatjaf","about":"buy my merch at fiatjaf store","picture":"https://fiatjaf.com/static/favicon.jpg","nip05":"_@fiatjaf.com"}'
}
describe('finishEvent', () => {
it('should create a signed event from a template', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const privateKey =
'5c6c25b7ef18d8633e97512159954e1aa22809c6b763e94b9f91071836d00217'
const template = {
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115
}
test('validate event', () => {
expect(validateEvent(event)).toBeTruthy()
})
test('check signature', async () => {
expect(verifySignature(event)).toBeTruthy()
})
test('sign event', async () => {
let pubkey = getPublicKey(privateKey)
let authored = {...unsigned, pubkey}
let sig = signEvent(authored, privateKey)
let signed = {...authored, sig}
expect(verifySignature(signed)).toBeTruthy()
const event = finishEvent(template, privateKey)
expect(event.kind).toEqual(template.kind)
expect(event.tags).toEqual(template.tags)
expect(event.content).toEqual(template.content)
expect(event.created_at).toEqual(template.created_at)
expect(event.pubkey).toEqual(publicKey)
expect(typeof event.id).toEqual('string')
expect(typeof event.sig).toEqual('string')
})
})
describe('serializeEvent', () => {
it('should serialize a valid event object', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
pubkey: publicKey,
created_at: 1617932115,
kind: Kind.Text,
tags: [],
content: 'Hello, world!'
}
const serializedEvent = serializeEvent(unsignedEvent)
expect(serializedEvent).toEqual(
JSON.stringify([
0,
publicKey,
unsignedEvent.created_at,
unsignedEvent.kind,
unsignedEvent.tags,
unsignedEvent.content
])
)
})
it('should throw an error for an invalid event object', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const invalidEvent = {
kind: Kind.Text,
tags: [],
created_at: 1617932115,
pubkey: publicKey // missing content
}
expect(() => {
serializeEvent(invalidEvent)
}).toThrow("can't serialize event with wrong or missing properties")
})
})
describe('getEventHash', () => {
it('should return the correct event hash', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey
}
const eventHash = getEventHash(unsignedEvent)
expect(typeof eventHash).toEqual('string')
expect(eventHash.length).toEqual(64)
})
})
describe('validateEvent', () => {
it('should return true for a valid event object', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey
}
const isValid = validateEvent(unsignedEvent)
expect(isValid).toEqual(true)
})
it('should return false for a non object event', () => {
const nonObjectEvent = ''
const isValid = validateEvent(nonObjectEvent)
expect(isValid).toEqual(false)
})
it('should return false for an event object with missing properties', () => {
const invalidEvent = {
kind: Kind.Text,
tags: [],
created_at: 1617932115 // missing content and pubkey
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
it('should return false for an empty object', () => {
const emptyObj = {}
const isValid = validateEvent(emptyObj)
expect(isValid).toEqual(false)
})
it('should return false for an object with invalid properties', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const invalidEvent = {
kind: 1,
tags: [],
created_at: '1617932115', // should be a number
pubkey: publicKey
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
it('should return false for an object with an invalid public key', () => {
const invalidEvent = {
kind: 1,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: 'invalid_pubkey'
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
it('should return false for an object with invalid tags', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const invalidEvent = {
kind: 1,
tags: {}, // should be an array
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
})
describe('verifySignature', () => {
it('should return true for a valid event signature', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const event = finishEvent(
{
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115
},
privateKey
)
const isValid = verifySignature(event)
expect(isValid).toEqual(true)
})
it('should return false for an invalid event signature', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const event = finishEvent(
{
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115
},
privateKey
)
// tamper with the signature
event.sig = event.sig.replace(/0/g, '1')
const isValid = verifySignature(event)
expect(isValid).toEqual(false)
})
it('should return false when verifying an event with a different private key', () => {
const privateKey1 =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const privateKey2 =
'5b4a34f4e4b23c63ad55a35e3f84a3b53d96dbf266edf521a8358f71d19cbf67'
const publicKey2 = getPublicKey(privateKey2)
const event = finishEvent(
{
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115
},
privateKey1
)
// verify with different private key
const isValid = verifySignature({
...event,
pubkey: publicKey2
})
expect(isValid).toEqual(false)
})
})
describe('signEvent', () => {
it('should sign an event object', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey
}
const sig = signEvent(unsignedEvent, privateKey)
// verify the signature
const isValid = verifySignature({
...unsignedEvent,
sig
})
expect(typeof sig).toEqual('string')
expect(sig.length).toEqual(128)
expect(isValid).toEqual(true)
})
it('should not sign an event with different private key', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const wrongPrivateKey =
'a91e2a9d9e0f70f0877bea0dbf034e8f95d7392a27a7f07da0d14b9e9d456be7'
const unsignedEvent = {
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey
}
const sig = signEvent(unsignedEvent, wrongPrivateKey)
// verify the signature
const isValid = verifySignature({
...unsignedEvent,
sig
})
expect(typeof sig).toEqual('string')
expect(sig.length).toEqual(128)
expect(isValid).toEqual(false)
})
})
})

View File

@@ -1,42 +1,176 @@
/* eslint-env jest */
const {matchFilters} = require('./lib/nostr.cjs')
const {matchFilter, matchFilters} = require('./lib/nostr.cjs.js')
test('test if filters match', () => {
;[
{
filters: [{ids: ['i']}],
good: [{id: 'i'}],
bad: [{id: 'j'}]
},
{
filters: [{authors: ['abc']}, {kinds: [1, 3]}],
good: [
{pubkey: 'xyz', kind: 3},
{pubkey: 'abc', kind: 12},
{pubkey: 'abc', kind: 1}
],
bad: [{pubkey: 'hhh', kind: 12}]
},
{
filters: [{'#e': ['yyy'], since: 444}],
good: [
{
tags: [
['e', 'uuu'],
['e', 'yyy']
],
created_at: 555
}
],
bad: [{tags: [['e', 'uuu']], created_at: 111}]
}
].forEach(({filters, good, bad}) => {
good.forEach(ev => {
expect(matchFilters(filters, ev)).toBeTruthy()
describe('Filter', () => {
describe('matchFilter', () => {
it('should return true when all filter conditions are met', () => {
const filter = {
ids: ['123', '456'],
kinds: [1, 2, 3],
authors: ['abc'],
since: 100,
until: 200,
'#tag': ['value']
}
const event = {
id: '123',
kind: 1,
pubkey: 'abc',
created_at: 150,
tags: [['tag', 'value']]
}
const result = matchFilter(filter, event)
expect(result).toEqual(true)
})
bad.forEach(ev => {
expect(matchFilters(filters, ev)).toBeFalsy()
it('should return false when the event id is not in the filter', () => {
const filter = {ids: ['123', '456']}
const event = {id: '789'}
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})
it('should return false when the event kind is not in the filter', () => {
const filter = {kinds: [1, 2, 3]}
const event = {kind: 4}
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})
it('should return false when the event author is not in the filter', () => {
const filter = {authors: ['abc', 'def']}
const event = {pubkey: 'ghi'}
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})
it('should return false when a tag is not present in the event', () => {
const filter = {'#tag': ['value1', 'value2']}
const event = {tags: [['not_tag', 'value1']]}
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})
it('should return false when a tag value is not present in the event', () => {
const filter = {'#tag': ['value1', 'value2']}
const event = {tags: [['tag', 'value3']]}
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})
it('should return true when filter has tags that is present in the event', () => {
const filter = {'#tag1': ['foo']}
const event = {
id: '123',
kind: 1,
pubkey: 'abc',
created_at: 150,
tags: [
['tag1', 'foo'],
['tag2', 'bar']
]
}
const result = matchFilter(filter, event)
expect(result).toEqual(true)
})
it('should return false when the event is before the filter since value', () => {
const filter = {since: 100}
const event = {created_at: 50}
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})
it('should return false when the event is after the filter until value', () => {
const filter = {until: 100}
const event = {created_at: 150}
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})
})
describe('matchFilters', () => {
it('should return true when at least one filter matches the event', () => {
const filters = [
{ids: ['123'], kinds: [1], authors: ['abc']},
{ids: ['456'], kinds: [2], authors: ['def']},
{ids: ['789'], kinds: [3], authors: ['ghi']}
]
const event = {id: '789', kind: 3, pubkey: 'ghi'}
const result = matchFilters(filters, event)
expect(result).toEqual(true)
})
it('should return true when event matches one or more filters and some have limit set', () => {
const filters = [
{ids: ['123'], limit: 1},
{kinds: [1], limit: 2},
{authors: ['abc'], limit: 3}
]
const event = {id: '123', kind: 1, pubkey: 'abc', created_at: 150}
const result = matchFilters(filters, event)
expect(result).toEqual(true)
})
it('should return false when no filters match the event', () => {
const filters = [
{ids: ['123'], kinds: [1], authors: ['abc']},
{ids: ['456'], kinds: [2], authors: ['def']},
{ids: ['789'], kinds: [3], authors: ['ghi']}
]
const event = {id: '100', kind: 4, pubkey: 'jkl'}
const result = matchFilters(filters, event)
expect(result).toEqual(false)
})
it('should return false when event matches none of the filters and some have limit set', () => {
const filters = [
{ids: ['123'], limit: 1},
{kinds: [1], limit: 2},
{authors: ['abc'], limit: 3}
]
const event = {id: '456', kind: 2, pubkey: 'def', created_at: 200}
const result = matchFilters(filters, event)
expect(result).toEqual(false)
})
})
})

View File

@@ -7,6 +7,7 @@ export type Filter = {
since?: number
until?: number
limit?: number
search?: string
[key: `#${string}`]: string[]
}

View File

@@ -3,12 +3,14 @@ export * from './relay'
export * from './event'
export * from './filter'
export * from './pool'
export * from './references'
export * as nip04 from './nip04'
export * as nip05 from './nip05'
export * as nip06 from './nip06'
export * as nip19 from './nip19'
export * as nip26 from './nip26'
export * as nip39 from './nip39'
export * as nip57 from './nip57'
export * as fj from './fakejson'

View File

@@ -37,6 +37,7 @@ export async function queryProfile(
}
if (!name.match(/^[A-Za-z0-9-_]+$/)) return null
if (!domain.includes('.')) return null
let res
try {

View File

@@ -35,6 +35,21 @@ test('encode and decode nprofile', () => {
expect(data.relays).toContain(relays[1])
})
test('decode nprofile without relays', () => {
expect(
nip19.decode(
nip19.nprofileEncode({
pubkey:
'97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322',
relays: []
})
).data
).toHaveProperty(
'pubkey',
'97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322'
)
})
test('encode and decode naddr', () => {
let pk = getPublicKey(generatePrivateKey())
let relays = [

View File

@@ -13,6 +13,7 @@ export type ProfilePointer = {
export type EventPointer = {
id: string // hex
relays?: string[]
author?: string
}
export type AddressPointer = {
@@ -47,12 +48,17 @@ export function decode(nip19: string): {
let tlv = parseTLV(data)
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nevent')
if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes')
if (tlv[2] && tlv[2][0].length !== 32)
throw new Error('TLV 2 should be 32 bytes')
return {
type: 'nevent',
data: {
id: secp256k1.utils.bytesToHex(tlv[0][0]),
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : []
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
author: tlv[2]?.[0]
? secp256k1.utils.bytesToHex(tlv[2][0])
: undefined
}
}
}
@@ -133,7 +139,8 @@ export function nprofileEncode(profile: ProfilePointer): string {
export function neventEncode(event: EventPointer): string {
let data = encodeTLV({
0: [secp256k1.utils.hexToBytes(event.id)],
1: (event.relays || []).map(url => utf8Encoder.encode(url))
1: (event.relays || []).map(url => utf8Encoder.encode(url)),
2: event.author ? [secp256k1.utils.hexToBytes(event.author)] : []
})
let words = bech32.toWords(data)
return bech32.encode('nevent', words, Bech32MaxSize)

15
nip39.test.js Normal file
View File

@@ -0,0 +1,15 @@
/* eslint-env jest */
const fetch = require('node-fetch')
const {nip39} = require('./lib/nostr.cjs.js')
test('validate github claim', async () => {
nip39.useFetchImplementation(fetch)
let result = await nip39.validateGithub(
'npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z',
'vitorpamplona',
'cf19e2d1d7f8dac6348ad37b35ec8421'
)
expect(result).toBe(true)
})

27
nip39.ts Normal file
View File

@@ -0,0 +1,27 @@
var _fetch: any
try {
_fetch = fetch
} catch {}
export function useFetchImplementation(fetchImplementation: any) {
_fetch = fetchImplementation
}
export async function validateGithub(
pubkey: string,
username: string,
proof: string
): Promise<boolean> {
try {
let res = await (
await _fetch(`https://gist.github.com/${username}/${proof}/raw`)
).text()
return (
res ===
`Verifying that I control the following Nostr public key: ${pubkey}`
)
} catch (_) {
return false
}
}

335
nip57.test.js Normal file
View File

@@ -0,0 +1,335 @@
const {bech32} = require('@scure/base')
const {
nip57,
generatePrivateKey,
getPublicKey,
finishEvent
} = require('./lib/nostr.cjs')
describe('getZapEndpoint', () => {
test('returns null if neither lud06 nor lud16 is present', async () => {
const metadata = {content: '{}'}
const result = await nip57.getZapEndpoint(metadata)
expect(result).toBeNull()
})
test('returns null if fetch fails', async () => {
const fetchImplementation = jest.fn(() => Promise.reject(new Error()))
nip57.useFetchImplementation(fetchImplementation)
const metadata = {content: '{"lud16": "name@domain"}'}
const result = await nip57.getZapEndpoint(metadata)
expect(result).toBeNull()
expect(fetchImplementation).toHaveBeenCalledWith(
'https://domain/.well-known/lnurlp/name'
)
})
test('returns null if the response does not allow Nostr payments', async () => {
const fetchImplementation = jest.fn(() =>
Promise.resolve({json: () => ({allowsNostr: false})})
)
nip57.useFetchImplementation(fetchImplementation)
const metadata = {content: '{"lud16": "name@domain"}'}
const result = await nip57.getZapEndpoint(metadata)
expect(result).toBeNull()
expect(fetchImplementation).toHaveBeenCalledWith(
'https://domain/.well-known/lnurlp/name'
)
})
test('returns the callback URL if the response allows Nostr payments', async () => {
const fetchImplementation = jest.fn(() =>
Promise.resolve({
json: () => ({
allowsNostr: true,
nostrPubkey: 'pubkey',
callback: 'callback'
})
})
)
nip57.useFetchImplementation(fetchImplementation)
const metadata = {content: '{"lud16": "name@domain"}'}
const result = await nip57.getZapEndpoint(metadata)
expect(result).toBe('callback')
expect(fetchImplementation).toHaveBeenCalledWith(
'https://domain/.well-known/lnurlp/name'
)
})
})
describe('makeZapRequest', () => {
test('throws an error if amount is not given', () => {
expect(() =>
nip57.makeZapRequest({
profile: 'profile',
event: null,
relays: [],
comment: ''
})
).toThrow()
})
test('throws an error if profile is not given', () => {
expect(() =>
nip57.makeZapRequest({
event: null,
amount: 100,
relays: [],
comment: ''
})
).toThrow()
})
test('returns a valid Zap request', () => {
const result = nip57.makeZapRequest({
profile: 'profile',
event: 'event',
amount: 100,
relays: ['relay1', 'relay2'],
comment: 'comment'
})
expect(result.kind).toBe(9734)
expect(result.created_at).toBeCloseTo(Date.now() / 1000, 0)
expect(result.content).toBe('comment')
expect(result.tags).toEqual(
expect.arrayContaining([
['p', 'profile'],
['amount', '100'],
['relays', 'relay1', 'relay2']
])
)
expect(result.tags).toContainEqual(['e', 'event'])
})
})
describe('validateZapRequest', () => {
test('returns an error message for invalid JSON', () => {
expect(nip57.validateZapRequest('invalid JSON')).toBe(
'Invalid zap request JSON.'
)
})
test('returns an error message if the Zap request is not a valid Nostr event', () => {
const zapRequest = {
kind: 1234,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', 'profile'],
['amount', '100'],
['relays', 'relay1', 'relay2']
]
}
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
'Zap request is not a valid Nostr event.'
)
})
test('returns an error message if the signature on the Zap request is invalid', () => {
const privateKey = generatePrivateKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = {
pubkey: publicKey,
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', publicKey],
['amount', '100'],
['relays', 'relay1', 'relay2']
]
}
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
'Invalid signature on zap request.'
)
})
test('returns an error message if the Zap request does not have a "p" tag', () => {
const privateKey = generatePrivateKey()
const zapRequest = finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['amount', '100'],
['relays', 'relay1', 'relay2']
]
},
privateKey
)
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
"Zap request doesn't have a 'p' tag."
)
})
test('returns an error message if the "p" tag on the Zap request is not valid hex', () => {
const privateKey = generatePrivateKey()
const zapRequest = finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', 'invalid hex'],
['amount', '100'],
['relays', 'relay1', 'relay2']
]
},
privateKey
)
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
"Zap request 'p' tag is not valid hex."
)
})
test('returns an error message if the "e" tag on the Zap request is not valid hex', () => {
const privateKey = generatePrivateKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', publicKey],
['e', 'invalid hex'],
['amount', '100'],
['relays', 'relay1', 'relay2']
]
},
privateKey
)
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
"Zap request 'e' tag is not valid hex."
)
})
test('returns an error message if the Zap request does not have a relays tag', () => {
const privateKey = generatePrivateKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', publicKey],
['amount', '100']
]
},
privateKey
)
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBe(
"Zap request doesn't have a 'relays' tag."
)
})
test('returns null for a valid Zap request', () => {
const privateKey = generatePrivateKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', publicKey],
['amount', '100'],
['relays', 'relay1', 'relay2']
]
},
privateKey
)
expect(nip57.validateZapRequest(JSON.stringify(zapRequest))).toBeNull()
})
})
describe('makeZapReceipt', () => {
test('returns a valid Zap receipt with a preimage', () => {
const privateKey = generatePrivateKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = JSON.stringify(
finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', publicKey],
['amount', '100'],
['relays', 'relay1', 'relay2']
]
},
privateKey
)
)
const preimage = 'preimage'
const bolt11 = 'bolt11'
const paidAt = new Date()
const result = nip57.makeZapReceipt({zapRequest, preimage, bolt11, paidAt})
expect(result.kind).toBe(9735)
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
expect(result.content).toBe('')
expect(result.tags).toContainEqual(['bolt11', bolt11])
expect(result.tags).toContainEqual(['description', zapRequest])
expect(result.tags).toContainEqual(['p', publicKey])
expect(result.tags).toContainEqual(['preimage', preimage])
})
test('returns a valid Zap receipt without a preimage', () => {
const privateKey = generatePrivateKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = JSON.stringify(
finishEvent(
{
kind: 9734,
created_at: Date.now() / 1000,
content: 'content',
tags: [
['p', publicKey],
['amount', '100'],
['relays', 'relay1', 'relay2']
]
},
privateKey
)
)
const bolt11 = 'bolt11'
const paidAt = new Date()
const result = nip57.makeZapReceipt({zapRequest, bolt11, paidAt})
expect(result.kind).toBe(9735)
expect(result.created_at).toBeCloseTo(paidAt.getTime() / 1000, 0)
expect(result.content).toBe('')
expect(result.tags).toContainEqual(['bolt11', bolt11])
expect(result.tags).toContainEqual(['description', zapRequest])
expect(result.tags).toContainEqual(['p', publicKey])
expect(result.tags).not.toContain('preimage')
})
})

View File

@@ -1,6 +1,6 @@
{
"name": "nostr-tools",
"version": "1.7.4",
"version": "1.8.1",
"description": "Tools for making a Nostr client.",
"repository": {
"type": "git",
@@ -16,6 +16,7 @@
"import": "./lib/esm/nostr.mjs",
"require": "./lib/nostr.cjs.js"
},
"license": "Public domain",
"dependencies": {
"@noble/hashes": "1.0.0",
"@noble/secp256k1": "^1.7.1",

47
pool.ts
View File

@@ -27,7 +27,12 @@ export class SimplePool {
async ensureRelay(url: string): Promise<Relay> {
const nm = normalizeURL(url)
const existing = this._conn[nm]
if (existing) return existing
if (existing && existing.status === 1) return existing
if (existing) {
await existing.connect()
return existing
}
const relay = relayInit(nm, {
getTimeout: this.getTimeout * 0.9,
@@ -42,8 +47,11 @@ export class SimplePool {
sub(relays: string[], filters: Filter[], opts?: SubscriptionOptions): Sub {
let _knownIds: Set<string> = new Set()
let modifiedOpts = opts || {}
let modifiedOpts = {...(opts || {})}
modifiedOpts.alreadyHaveEvent = (id, url) => {
if (opts?.alreadyHaveEvent?.(id, url)) {
return true
}
let set = this._seenOn[id] || new Set()
set.add(url)
this._seenOn[id] = set
@@ -99,19 +107,17 @@ export class SimplePool {
subs.forEach(sub => sub.unsub())
},
on(type, cb) {
switch (type) {
case 'event':
eventListeners.add(cb)
break
case 'eose':
eoseListeners.add(cb)
break
if (type === 'event') {
eventListeners.add(cb)
} else if (type === 'eose') {
eoseListeners.add(cb as () => void | Promise<void>)
}
},
off(type, cb) {
if (type === 'event') {
eventListeners.delete(cb)
} else if (type === 'eose') eoseListeners.delete(cb)
} else if (type === 'eose')
eoseListeners.delete(cb as () => void | Promise<void>)
}
}
@@ -159,12 +165,14 @@ export class SimplePool {
}
publish(relays: string[], event: Event): Pub {
let pubs = relays.map(relay => {
let r = this._conn[normalizeURL(relay)]
if (!r) return badPub(relay)
return r.publish(event)
const pubs: Pub[] = []
relays.forEach(async relay => {
let r
try {
r = await this.ensureRelay(relay)
pubs.push(r.publish(event))
} catch (_) {}
})
return {
on(type, cb) {
pubs.forEach((pub, i) => {
@@ -181,12 +189,3 @@ export class SimplePool {
return Array.from(this._seenOn[id]?.values?.() || [])
}
}
function badPub(relay: string): Pub {
return {
on(typ, cb) {
if (typ === 'failed') cb(`relay ${relay} not connected`)
},
off() {}
}
}

62
references.test.js Normal file
View File

@@ -0,0 +1,62 @@
/* eslint-env jest */
const {parseReferences} = require('./lib/nostr.cjs')
test('parse mentions', () => {
let evt = {
tags: [
[
'p',
'c9d556c6d3978d112d30616d0d20aaa81410e3653911dd67787b5aaf9b36ade8',
'wss://nostr.com'
],
[
'e',
'a84c5de86efc2ec2cff7bad077c4171e09146b633b7ad117fffe088d9579ac33',
'wss://other.com',
'reply'
],
[
'e',
'31d7c2875b5fc8e6f9c8f9dc1f84de1b6b91d1947ea4c59225e55c325d330fa8',
''
]
],
content:
'hello #[0], have you seen #[2]? it was made by nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg on nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4! broken #[3]'
}
expect(parseReferences(evt)).toEqual([
{
text: '#[0]',
profile: {
pubkey:
'c9d556c6d3978d112d30616d0d20aaa81410e3653911dd67787b5aaf9b36ade8',
relays: ['wss://nostr.com']
}
},
{
text: '#[2]',
event: {
id: '31d7c2875b5fc8e6f9c8f9dc1f84de1b6b91d1947ea4c59225e55c325d330fa8',
relays: []
}
},
{
text: 'nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg',
profile: {
pubkey:
'cc6b9fea033f59c3c39a0407c5f1bfee439b077508d918cfdc0d6fd431d39393',
relays: []
}
},
{
text: 'nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4',
event: {
id: 'cc6b9fea033f59c3c39a0407c5f1bfee439b077508d918cfdc0d6fd431d39393',
relays: [],
author: undefined
}
}
])
})

104
references.ts Normal file
View File

@@ -0,0 +1,104 @@
import {Event} from './event'
import {decode, AddressPointer, ProfilePointer, EventPointer} from './nip19'
type Reference = {
text: string
profile?: ProfilePointer
event?: EventPointer
address?: AddressPointer
}
const mentionRegex =
/\bnostr:((note|npub|naddr|nevent|nprofile)1\w+)\b|#\[(\d+)\]/g
export function parseReferences(evt: Event): Reference[] {
let references: Reference[] = []
for (let ref of evt.content.matchAll(mentionRegex)) {
if (ref[2]) {
// it's a NIP-27 mention
try {
let {type, data} = decode(ref[1])
switch (type) {
case 'npub': {
references.push({
text: ref[0],
profile: {pubkey: data as string, relays: []}
})
break
}
case 'nprofile': {
references.push({
text: ref[0],
profile: data as ProfilePointer
})
break
}
case 'note': {
references.push({
text: ref[0],
event: {id: data as string, relays: []}
})
break
}
case 'nevent': {
references.push({
text: ref[0],
event: data as EventPointer
})
break
}
case 'naddr': {
references.push({
text: ref[0],
address: data as AddressPointer
})
break
}
}
} catch (err) {
/***/
}
} else if (ref[3]) {
// it's a NIP-10 mention
let idx = parseInt(ref[3], 10)
let tag = evt.tags[idx]
if (!tag) continue
switch (tag[0]) {
case 'p': {
references.push({
text: ref[0],
profile: {pubkey: tag[1], relays: tag[2] ? [tag[2]] : []}
})
break
}
case 'e': {
references.push({
text: ref[0],
event: {id: tag[1], relays: tag[2] ? [tag[2]] : []}
})
break
}
case 'a': {
try {
let [kind, pubkey, identifier] = ref[1].split(':')
references.push({
text: ref[0],
address: {
identifier,
pubkey,
kind: parseInt(kind, 10),
relays: tag[2] ? [tag[2]] : []
}
})
} catch (err) {
/***/
}
break
}
}
}
}
return references
}

View File

@@ -4,8 +4,16 @@ import {Event, verifySignature, validateEvent} from './event'
import {Filter, matchFilters} from './filter'
import {getHex64, getSubscriptionId} from './fakejson'
type RelayEvent = 'connect' | 'disconnect' | 'error' | 'notice'
type RelayEvent = {
connect: () => void | Promise<void>
disconnect: () => void | Promise<void>
error: () => void | Promise<void>
notice: (msg: string) => void | Promise<void>
}
type SubEvent = {
event: (event: Event) => void | Promise<void>
eose: () => void | Promise<void>
}
export type Relay = {
url: string
status: number
@@ -15,8 +23,14 @@ export type Relay = {
list: (filters: Filter[], opts?: SubscriptionOptions) => Promise<Event[]>
get: (filter: Filter, opts?: SubscriptionOptions) => Promise<Event | null>
publish: (event: Event) => Pub
on: (type: RelayEvent, cb: any) => void
off: (type: RelayEvent, cb: any) => void
off: <T extends keyof RelayEvent, U extends RelayEvent[T]>(
event: T,
listener: U
) => void
on: <T extends keyof RelayEvent, U extends RelayEvent[T]>(
event: T,
listener: U
) => void
}
export type Pub = {
on: (type: 'ok' | 'failed', cb: any) => void
@@ -25,8 +39,14 @@ export type Pub = {
export type Sub = {
sub: (filters: Filter[], opts: SubscriptionOptions) => Sub
unsub: () => void
on: (type: 'event' | 'eose', cb: any) => void
off: (type: 'event' | 'eose', cb: any) => void
on: <T extends keyof SubEvent, U extends SubEvent[T]>(
event: T,
listener: U
) => void
off: <T extends keyof SubEvent, U extends SubEvent[T]>(
event: T,
listener: U
) => void
}
export type SubscriptionOptions = {
@@ -46,22 +66,14 @@ export function relayInit(
var ws: WebSocket
var openSubs: {[id: string]: {filters: Filter[]} & SubscriptionOptions} = {}
var listeners: {
connect: Array<() => void>
disconnect: Array<() => void>
error: Array<() => void>
notice: Array<(msg: string) => void>
} = {
var listeners: {[TK in keyof RelayEvent]: RelayEvent[TK][]} = {
connect: [],
disconnect: [],
error: [],
notice: []
}
var subListeners: {
[subid: string]: {
event: Array<(event: Event) => void>
eose: Array<() => void>
}
[subid: string]: {[TK in keyof SubEvent]: SubEvent[TK][]}
} = {}
var pubListeners: {
[eventid: string]: {
@@ -73,7 +85,11 @@ export function relayInit(
async function connectRelay(): Promise<void> {
return new Promise((resolve, reject) => {
ws = new WebSocket(url)
try {
ws = new WebSocket(url)
} catch (err) {
reject(err)
}
ws.onopen = () => {
listeners.connect.forEach(cb => cb())
@@ -225,14 +241,20 @@ export function relayInit(
delete subListeners[subid]
trySend(['CLOSE', subid])
},
on: (type: 'event' | 'eose', cb: any): void => {
on: <T extends keyof SubEvent, U extends SubEvent[T]>(
type: T,
cb: U
): void => {
subListeners[subid] = subListeners[subid] || {
event: [],
eose: []
}
subListeners[subid][type].push(cb)
},
off: (type: 'event' | 'eose', cb: any): void => {
off: <T extends keyof SubEvent, U extends SubEvent[T]>(
type: T,
cb: U
): void => {
let listeners = subListeners[subid]
let idx = listeners[type].indexOf(cb)
if (idx >= 0) listeners[type].splice(idx, 1)
@@ -243,13 +265,20 @@ export function relayInit(
return {
url,
sub,
on: (type: RelayEvent, cb: any): void => {
on: <T extends keyof RelayEvent, U extends RelayEvent[T]>(
type: T,
cb: U
): void => {
listeners[type].push(cb)
if (type === 'connect' && ws?.readyState === 1) {
cb()
// i would love to know why we need this
;(cb as () => void)()
}
},
off: (type: RelayEvent, cb: any): void => {
off: <T extends keyof RelayEvent, U extends RelayEvent[T]>(
type: T,
cb: U
): void => {
let index = listeners[type].indexOf(cb)
if (index !== -1) listeners[type].splice(index, 1)
},
@@ -310,8 +339,9 @@ export function relayInit(
listeners = {connect: [], disconnect: [], error: [], notice: []}
subListeners = {}
pubListeners = {}
ws?.close()
if (ws.readyState === WebSocket.OPEN) {
ws?.close()
}
},
get status() {
return ws?.readyState ?? 3