Compare commits

...

116 Commits

Author SHA1 Message Date
fiatjaf
55ff796b9f v1.10.1 2023-04-22 21:59:41 -03:00
fiatjaf_
3ef2ad5bc4 Merge pull request #194 from alexgleason/nip27 2023-04-22 21:58:36 -03:00
Alex Gleason
45c07a5f45 nip27: make matchAll a generator function 2023-04-22 19:22:06 -05:00
Alex Gleason
6a037d1658 nip27.find --> nip27.matchAll 2023-04-22 19:06:53 -05:00
Alex Gleason
dcf101c6c2 yarn format 2023-04-22 18:33:09 -05:00
Alex Gleason
eb97dbd9ef Add NIP-21 and NIP-27 modules for parsing nostr URIs 2023-04-22 18:30:57 -05:00
Alex Gleason
92988051c6 Add Unlicense 2023-04-20 12:22:39 -03:00
fiatjaf
bf7e00d32a hotfix types. 2023-04-18 15:29:28 -03:00
fiatjaf
9241089997 v1.10.0 with @noble/secp256k1 back, nip42 support and nip19 typing improvements. 2023-04-18 15:17:57 -03:00
Lynn Zenn
32c47e9bd8 relay: separate auth() and publish() methods 2023-04-18 15:16:40 -03:00
Lynn Zenn
6e58fe371c relay: add support for NIP42 authentication 2023-04-18 15:16:40 -03:00
Lynn Zenn
26e35d50e0 relay: fix type errors 2023-04-18 15:16:40 -03:00
fiatjaf
ef3184a6e0 remove @noble/curves. people are not ready for it, causes BigInt issues. 2023-04-18 15:14:21 -03:00
Alex Gleason
56fe3dd5dd nip19: improve return type 2023-04-18 07:30:43 -03:00
Jonathan Staab
f1bb5030c8 Add support for count 2023-04-16 06:43:38 -03:00
fiatjaf
ac212cb5c8 tag v1.9.0 using @noble/curves. 2023-04-14 17:09:28 -03:00
Paul Miller
204ae0eff1 Switch from noble-secp256k1 to noble-curves 2023-04-14 16:45:01 -03:00
Alejandro Gomez
f17ab41d72 NIP-19: Add nrelay encoding and decoding 2023-04-14 13:26:31 -03:00
fiatjaf
f6f5ee8223 tag v1.8.4 2023-04-11 18:23:04 -03:00
Alex Gleason
a05506468d Add NIP-13 (proof-of-work) module 2023-04-11 18:21:40 -03:00
Agustin Kassis
674ff66b6f added badge events
Added the following badge kinds according to NIP 58
  BadgeDefinition: 30008,
  BadgeAward: 8,
  ProfileBadge: 30009,
2023-04-11 17:39:13 -03:00
channelninja
731705047a fix(nip05): allow dot in name 2023-04-09 19:32:58 -03:00
Alex Gleason
94b382a49f Fix validateEvent type checking 2023-04-08 17:53:31 -03:00
fiatjaf
199411a971 yarn.lock with deduped packages. 2023-04-08 15:44:37 -03:00
fiatjaf
a1dc6f41b9 fix conflict in noble dependencies. 2023-04-08 15:43:47 -03:00
Alex Gleason
5b59b93d86 validateEvent: use assertion function 2023-04-08 15:07:25 -03:00
OFF0
12acd7bdca scripts: add npm build, format and test scripts
in addition to the just tasks, this commit adds npm scripts back,
for convenience.

example taks:
- npm test
- npm test filter.test.js
- npm run build
- npm run format

in yarn it should just work without 'run' i.e. 'yarn build'.
2023-04-08 09:05:23 -03:00
OFF0
3bdb68020d nip01: add support for filter prefix in authors and ids
so clients can filter events by prefix in authors and ids as
described in nip-01, i.e. to subscribe to mined events starting
with zeroes or to add some privacy for clients that may not want
to disclose the exact filter.

see also https://github.com/scsibug/nostr-rs-relay/issues/104
2023-04-07 08:29:00 -03:00
Susumu OTA
b0a58e2ca4 fix: Event type has id and sig field. 2023-04-06 06:15:07 -03:00
Susumu OTA
b063be76ae fix: must be tag not ref. 2023-04-06 06:14:40 -03:00
fiatjaf
e3cea5db16 tag v1.8.2 2023-04-04 10:26:35 -03:00
fiatjaf
9ee58bd6c7 fix async race condition that caused pool.publish() callbacks to not be called.
fixes https://github.com/nbd-wtf/nostr-tools/issues/169
2023-04-04 10:26:23 -03:00
Steve Perkins
f1eb9a3bc7 Reuse connectionPromise for relay connect. 2023-04-04 08:10:51 -03:00
futpib
ce081bb4cb Rename pubkeys to profiles (NIP-10) 2023-04-02 10:04:04 -03:00
futpib
7413072e9f Fix pubkey relays lost in NIP-10 parsing 2023-04-02 08:34:36 -03:00
futpib
4c464b39cf Fix explicit NIP-10 root/reply/mention markers parsed incorrectly 2023-04-02 08:34:36 -03:00
futpib
11ef43abdc Run prettier 2023-04-02 08:34:36 -03:00
futpib
3e67f9b014 Add NIP-10 thread root/reply/mention parsing 2023-04-01 08:30:22 -03:00
futpib
0933fba6d5 Add .editorconfig 2023-04-01 08:18:57 -03:00
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
fiatjaf
f992c9c967 cleanup lib/ so we can only publish the esm file under esm/ 2023-03-02 08:36:43 -03:00
fiatjaf
dbf625d6ac fix justfile emit-types. 2023-03-02 08:27:44 -03:00
fiatjaf
8622bd11dd replace test relays. 2023-03-02 08:24:50 -03:00
fiatjaf
0970eee70f remove log from test. 2023-03-02 08:22:30 -03:00
fiatjaf
086f8830e3 catch fetch error on nip05. 2023-03-02 08:21:17 -03:00
Egge
e48d722227 Fixed readme for publishing with pool 2023-03-01 15:29:24 -03:00
Fernando López Guevara
0d77013aab chore(dx): add format script 💅 (#128) 2023-02-28 12:19:10 -03:00
BilligsterUser
4c415280aa ci emit types on publish 2023-02-27 22:17:52 -03:00
fiatjaf
4188aaf7c8 just type-check 2023-02-27 19:53:12 -03:00
BilligsterUser
673f4abab8 add type definition
fixes #138
2023-02-27 19:51:19 -03:00
BilligsterUser
bcefaa0757 update repo url 2023-02-27 19:49:00 -03:00
fiatjaf
649af36a86 one more nip19 test. 2023-02-27 16:10:26 -03:00
Simon
96a6f7af87 ensure kind has type 'number' in validateEvent 2023-02-27 14:22:36 -03:00
fiatjaf
a4c713efcb fix readme example.
fixes https://github.com/nbd-wtf/nostr-tools/issues/136
2023-02-27 12:43:24 -03:00
fiatjaf
9d345a8f01 configurable list and get timeout on relay. 2023-02-26 21:23:09 -03:00
fiatjaf
c362212778 make pool.publish() return a single Pub object. 2023-02-26 17:44:51 -03:00
fiatjaf
a8938a3a0f wait a second before failing to send on a not yet connected websocket. 2023-02-26 16:53:03 -03:00
fiatjaf
a21329da3f make timeouts configurable for pool. 2023-02-26 16:50:49 -03:00
fiatjaf
63f4a49a69 increase pool timeouts. 2023-02-26 15:05:26 -03:00
fiatjaf
27749d91b8 fix nip19: relays TLV items are optional. 2023-02-26 07:44:22 -03:00
michaelhall923
9530849f0a Fix pool sub example 2023-02-26 07:43:35 -03:00
fiatjaf
b8aa75b6e1 change a map to a forEach. 2023-02-24 09:41:26 -03:00
fiatjaf
344762820c handle connection failure on pool according to @chmac.
fixes https://github.com/nbd-wtf/nostr-tools/issues/130
2023-02-24 09:34:23 -03:00
Fernando López Guevara
f43d23d344 fix(relay): prevent accesing to ws if it is undefined 2023-02-23 15:20:10 -03:00
fiatjaf
bf55ad6b5a bump to v1.6.0 2023-02-20 22:51:49 -03:00
Moe Jangda
04a46b815c include the exports property in the root package.json to allow node environments to use cjs or esm bundles 2023-02-19 20:54:55 -03:00
Moe Jangda
165ff44dff include package.json with type: module near esm bundle so that it's usable 2023-02-19 20:54:55 -03:00
BilligsterUser
7bfd23af3c update close() usage
Signed-off-by: BilligsterUser <billigsteruser@protonmail.com>
2023-02-17 21:07:59 -03:00
fiatjaf
3d93ec8446 remove resolveClose, close() is now fire-and-forget. 2023-02-17 14:51:56 -03:00
fiatjaf
0f841138cd bump to v1.5.0 2023-02-17 14:31:08 -03:00
fiatjaf
336948b1d1 zap request validator. 2023-02-17 14:30:49 -03:00
Callum Macdonald
d46794c681 Also fix signEvent() type. 2023-02-17 14:30:27 -03:00
Callum Macdonald
93cef5d886 Type unsigned events. fix #117 2023-02-17 14:30:27 -03:00
fiatjaf
2324f9548e fail on null amount on zaprequest creation. 2023-02-16 13:56:47 -03:00
Roland Bewick
f9748d9cc3 doc: fix order of commands to connect to relay 2023-02-16 12:35:13 -03:00
Roland Bewick
3a22dd3da6 doc: add installation instructions 2023-02-16 12:29:09 -03:00
fiatjaf
d13039dc11 finishEvent() takes an EventTemplate and returns an Event. 2023-02-16 11:58:17 -03:00
fiatjaf
95b03902cc add support for naddr. 2023-02-16 11:27:50 -03:00
fiatjaf
ab5ea8de36 another nip57 helper and bump version. 2023-02-16 09:29:21 -03:00
fiatjaf
a330b97590 partial nip57 support. 2023-02-15 21:06:38 -03:00
fiatjaf
24406b5679 more automatic cleanup of event listeners. 2023-02-15 20:36:22 -03:00
fiatjaf
6dbcc87d93 delete listeners when closing a relay connection. 2023-02-15 20:31:25 -03:00
fiatjaf
0ddcfdce68 remove "seen" event from Pub.
too complicated. if anyone wants this they can do it themselves.
2023-02-15 20:21:29 -03:00
fiatjaf
87bf349ce8 fill in missing kinds on enum. 2023-02-14 16:04:18 -03:00
fiatjaf
54dfc7b972 validate that the event is an object. 2023-02-14 15:18:39 -03:00
fiatjaf
32793146a4 remove untilOpen promise that was causing memory leaks when a connection was never opened. 2023-02-14 11:24:30 -03:00
fiatjaf
c42cd925ce bump noble-hashes. 2023-02-13 21:26:42 -03:00
RbnRncn
43ccb72476 docs: import SimplePool fix
Small fix of import SimplePool in new multiple relay docs.
2023-02-12 08:41:22 -03:00
fiatjaf
b2b7999517 notice about just.
closes https://github.com/nbd-wtf/nostr-tools/pull/106
2023-02-09 22:02:07 -03:00
fiatjaf
a568afc295 remove this extraneous file. 2023-02-09 22:01:01 -03:00
fiatjaf
9bcaed6e60 fix tests, .seenOn() method for pools. 2023-02-09 22:01:01 -03:00
Fernando López Guevara
5a9cbbb557 feat(deps): upgrade dependencies 2023-02-09 21:59:37 -03:00
42 changed files with 6648 additions and 479 deletions

9
.editorconfig Normal file
View File

@@ -0,0 +1,9 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

View File

@@ -16,6 +16,7 @@ jobs:
- run: just install-dependencies
- run: just build
- run: just test
- run: just emit-types
- uses: JS-DevTools/npm-publish@v1
with:
token: ${{ secrets.NPM_TOKEN }}

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

24
LICENSE Normal file
View File

@@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org>

View File

@@ -4,6 +4,12 @@ Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
Only depends on _@scure_ and _@noble_ packages.
## Installation
```bash
npm install nostr-tools # or yarn add nostr-tools
```
## Usage
### Generating a private key and a public key
@@ -53,8 +59,6 @@ import {
} from 'nostr-tools'
const relay = relayInit('wss://relay.example.com')
await relay.connect()
relay.on('connect', () => {
console.log(`connected to ${relay.url}`)
})
@@ -62,6 +66,8 @@ relay.on('error', () => {
console.log(`failed to connect to ${relay.url}`)
})
await relay.connect()
// let's query for an event that exists
let sub = relay.sub([
{
@@ -104,9 +110,6 @@ let pub = relay.publish(event)
pub.on('ok', () => {
console.log(`${relay.url} has accepted our event`)
})
pub.on('seen', () => {
console.log(`we saw the event on ${relay.url}`)
})
pub.on('failed', reason => {
console.log(`failed to publish to ${relay.url}: ${reason}`)
})
@@ -116,7 +119,7 @@ let event = await relay.get({
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245']
})
await relay.close()
relay.close()
```
To use this on Node.js you first must install `websocket-polyfill` and import it:
@@ -128,36 +131,63 @@ import 'websocket-polyfill'
### Interacting with multiple relays
```js
import {pool} from 'nostr-tools'
import {SimplePool} from 'nostr-tools'
const pool = new SimplePool()
let relays = ['wss://relay.example.com', 'wss://relay.example2.com']
let relay = await pool.ensureRelay('wss://relay.example3.com')
let sub = pool.sub(
[...relays, 'wss://relay.example3.com'],
[
{
authors: [
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
]
}
]
)
let subs = pool.sub([...relays, relay], {
authors: ['32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245']
sub.on('event', event => {
// this will only be called once the first time the event is received
// ...
})
subs.forEach(sub =>
sub.on('event', event => {
// this will only be called once the first time the event is received
// ...
})
)
let pubs = pool.publish(relays, newEvent)
pubs.forEach(pub =>
pub.on('ok', () => {
// ...
})
)
pubs.on('ok', () => {
// this may be called multiple times, once for every relay that accepts the event
// ...
})
let events = await pool.list(relays, [{kinds: [0, 1]}])
let event = await pool.get(relays, {
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245']
})
let relaysForEvent = pool.seenOn(
'44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
)
// 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
@@ -288,6 +318,11 @@ Please consult the tests or [the source code](https://github.com/fiatjaf/nostr-t
</script>
```
## Plumbing
1. Install [`just`](https://just.systems/)
2. `just -l`
## License
Public domain.
This is free and unencumbered software released into the public domain. By submitting patches to this project, you agree to dedicate any and all copyright interest in this software to the public domain.

View File

@@ -1,5 +1,6 @@
#!/usr/bin/env node
const fs = require('fs')
const esbuild = require('esbuild')
let common = {
@@ -11,11 +12,16 @@ let common = {
esbuild
.build({
...common,
outfile: 'lib/nostr.esm.js',
outfile: 'lib/esm/nostr.mjs',
format: 'esm',
packages: 'external'
})
.then(() => console.log('esm build success.'))
.then(() => {
const packageJson = JSON.stringify({type: 'module'})
fs.writeFileSync(`${__dirname}/lib/esm/package.json`, packageJson, 'utf8')
console.log('esm build success.')
})
esbuild
.build({

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

@@ -2,6 +2,7 @@ import * as secp256k1 from '@noble/secp256k1'
import {sha256} from '@noble/hashes/sha256'
import {utf8Encoder} from './utils'
import {getPublicKey} from './keys'
/* eslint-disable no-unused-vars */
export enum Kind {
@@ -12,34 +13,56 @@ export enum Kind {
EncryptedDirectMessage = 4,
EventDeletion = 5,
Reaction = 7,
BadgeAward = 8,
ChannelCreation = 40,
ChannelMetadata = 41,
ChannelMessage = 42,
ChannelHideMessage = 43,
ChannelMuteUser = 44
ChannelMuteUser = 44,
Report = 1984,
ZapRequest = 9734,
Zap = 9735,
RelayList = 10002,
ClientAuth = 22242,
BadgeDefinition = 30008,
ProfileBadge = 30009,
Article = 30023
}
export type Event = {
id?: string
sig?: string
export type EventTemplate = {
kind: Kind
tags: string[][]
pubkey: string
content: string
created_at: number
}
export function getBlankEvent(): Event {
export type UnsignedEvent = EventTemplate & {
pubkey: string
}
export type Event = UnsignedEvent & {
id: string
sig: string
}
export function getBlankEvent(): EventTemplate {
return {
kind: 255,
pubkey: '',
content: '',
tags: [],
created_at: 0
}
}
export function serializeEvent(evt: Event): string {
export function finishEvent(t: EventTemplate, privateKey: string): Event {
let event = t as Event
event.pubkey = getPublicKey(privateKey)
event.id = getEventHash(event)
event.sig = signEvent(event, privateKey)
return event
}
export function serializeEvent(evt: UnsignedEvent): string {
if (!validateEvent(evt))
throw new Error("can't serialize event with wrong or missing properties")
@@ -53,12 +76,16 @@ export function serializeEvent(evt: Event): string {
])
}
export function getEventHash(event: Event): string {
export function getEventHash(event: UnsignedEvent): string {
let eventHash = sha256(utf8Encoder.encode(serializeEvent(event)))
return secp256k1.utils.bytesToHex(eventHash)
}
export function validateEvent(event: Event): boolean {
const isRecord = (obj: unknown): obj is Record<string, unknown> => obj instanceof Object
export function validateEvent<T>(event: T): event is T & UnsignedEvent {
if (!isRecord(event)) return false
if (typeof event.kind !== 'number') return false
if (typeof event.content !== 'string') return false
if (typeof event.created_at !== 'number') return false
if (typeof event.pubkey !== 'string') return false
@@ -76,7 +103,7 @@ export function validateEvent(event: Event): boolean {
return true
}
export function verifySignature(event: Event & {sig: string}): boolean {
export function verifySignature(event: Event): boolean {
return secp256k1.schnorr.verifySync(
event.sig,
getEventHash(event),
@@ -84,7 +111,7 @@ export function verifySignature(event: Event & {sig: string}): boolean {
)
}
export function signEvent(event: Event, key: string): string {
export function signEvent(event: UnsignedEvent, key: string): string {
return secp256k1.utils.bytesToHex(
secp256k1.schnorr.signSync(getEventHash(event), key)
)

View File

@@ -1,42 +1,200 @@
/* 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 true when the event id starts with a prefix', () => {
const filter = {ids: ['22', '00']}
const event = {id: '001'}
const result = matchFilter(filter, event)
expect(result).toEqual(true)
})
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 at least one prefix matches the event', () => {
const filters = [
{ids: ['1'], kinds: [1], authors: ['a']},
{ids: ['4'], kinds: [2], authors: ['d']},
{ids: ['9'], kinds: [3], authors: ['g']}
]
const event = {id: '987', 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,17 +7,25 @@ export type Filter = {
since?: number
until?: number
limit?: number
search?: string
[key: `#${string}`]: string[]
}
export function matchFilter(
filter: Filter,
event: Event & {id: string}
event: Event
): boolean {
if (filter.ids && filter.ids.indexOf(event.id) === -1) return false
if (filter.ids && filter.ids.indexOf(event.id) === -1) {
if (!filter.ids.some(prefix => event.id.startsWith(prefix))) {
return false
}
}
if (filter.kinds && filter.kinds.indexOf(event.kind) === -1) return false
if (filter.authors && filter.authors.indexOf(event.pubkey) === -1)
return false
if (filter.authors && filter.authors.indexOf(event.pubkey) === -1) {
if (!filter.authors.some(prefix => event.pubkey.startsWith(prefix))) {
return false
}
}
for (let f in filter) {
if (f[0] === '#') {
@@ -41,7 +49,7 @@ export function matchFilter(
export function matchFilters(
filters: Filter[],
event: Event & {id: string}
event: Event
): boolean {
for (let i = 0; i < filters.length; i++) {
if (matchFilter(filters[i], event)) return true

View File

@@ -3,12 +3,20 @@ 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 nip10 from './nip10'
export * as nip13 from './nip13'
export * as nip19 from './nip19'
export * as nip21 from './nip21'
export * as nip26 from './nip26'
export * as nip27 from './nip27'
export * as nip39 from './nip39'
export * as nip42 from './nip42'
export * as nip57 from './nip57'
export * as fj from './fakejson'
export * as utils from './utils'

View File

@@ -4,13 +4,20 @@ install-dependencies:
yarn --ignore-engines
build:
rm -rf lib
node build.js
test: build
jest
testOnly file: build
test-only file: build
jest {{file}}
publish: build
emit-types:
tsc # see tsconfig.json
publish: build emit-types
npm publish
format:
prettier --plugin-search-dir . --write .

140
magic.ts
View File

@@ -1,140 +0,0 @@
import {Relay, relayInit} from './relay'
import {Event} from './event'
import {normalizeURL} from './utils'
export default function (
writeableRelays: string[],
fallbackRelays: string[],
safeRelays: string[]
) {
return new MagicPool(fallbackRelays, writeableRelays, safeRelays)
}
class MagicPool {
private _conn: {[url: string]: Relay}
private _fallback: {[url: string]: Relay}
private _write: {[url: string]: Relay}
private _safe: {[url: string]: Relay}
private _profileRelays: {[pubkey: string]: RelayTableScore}
private _tempCache: {[id: string]: Event}
constructor(
fallbackRelays: string[],
writeableRelays: string[],
safeRelays: string[] = [
'wss://eden.nostr.land',
'wss://nostr.milou.lol',
'wss://relay.minds.com/nostr/v1/ws'
]
) {
this._conn = {}
this._write = {}
this._fallback = {}
this._profileRelays = {}
this._tempCache = {}
const hasEventId = (id: string): boolean => id in this._tempCache
const init = (url: string) => {
this._conn[normalizeURL(url)] = relayInit(normalizeURL(url), hasEventId)
}
fallbackRelays.forEach(init)
writeableRelays.forEach(init)
safeRelays.forEach(init)
this._write = Object.fromEntries(
writeableRelays.map(url => [
normalizeURL(url),
this._conn[normalizeURL(url)]
])
)
this._fallback = Object.fromEntries(
fallbackRelays.map(url => [
normalizeURL(url),
this._conn[normalizeURL(url)]
])
)
this._safe = Object.fromEntries(
safeRelays.map(url => [normalizeURL(url), this._conn[normalizeURL(url)]])
)
}
publish(event: Event) {
return Promise.all(
Object.entries(this._write).map(
([url, relay]) =>
new Promise(async resolve => {
await relay.connect()
let pub = relay.publish(event)
let to = setTimeout(() => {
let end = setTimeout(() => {
resolve({url, success: false, reason: 'timeout'})
}, 2500)
pub.on('seen', () => {
clearTimeout(end)
resolve({url, success: true, reason: 'seen'})
})
}, 2500)
pub.on('ok', () => {
clearTimeout(to)
resolve({url, success: true, reason: 'ok'})
})
pub.on('failed', (reason: string) => {
clearTimeout(to)
resolve({url, success: false, reason})
})
})
)
)
}
profile(
pubkey: string,
onUpdate: (events: Event[]) => void
): {
page(n: number): void
} {
var relays = new Set()
let rts = this._profileRelays[pubkey]
if (rts) {
relays = rts.get(3)
}
let fallback = Object.values(this._fallback)
for (let i = 0; i < fallback.length; i++) {
if (relays.size < 3) {
relays.add(fallback[Math.floor(Math.random() * fallback.length)])
} else break
}
// start subscription
for (let r in relays) {
r.
}
return {
page(n: number) {}
}
}
}
class RelayTableScore {
seen: string[] = []
hinted: string[] = []
explicit: string[] = []
get(n: number): Set<string> {
let relays = new Set<string>()
for (let i = 0; i < n; i++) {
for (let j = 0; j < 3; j++) {
let v = [this.seen, this.explicit, this.hinted][j][i]
if (v) {
relays.add(v)
if (relays.size >= n) return relays
}
}
}
return relays
}
}

View File

@@ -17,4 +17,9 @@ test('fetch nip05 profiles', async () => {
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
)
expect(p2.relays).toEqual(['wss://relay.damus.io'])
let p3 = await nip05.queryProfile('channel.ninja@channel.ninja')
expect(p3.pubkey).toEqual(
'36e65b503eba8a6b698e724a59137603101166a1cddb45ddc704247fc8aa0fce'
)
})

View File

@@ -36,11 +36,17 @@ export async function queryProfile(
name = '_'
}
if (!name.match(/^[A-Za-z0-9-_]+$/)) return null
if (!name.match(/^[A-Za-z0-9-_.]+$/)) return null
if (!domain.includes('.')) return null
let res = await (
await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
).json()
let res
try {
res = await (
await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
).json()
} catch (err) {
return null
}
if (!res?.names?.[name]) return null

View File

@@ -4,12 +4,16 @@ const {nip06} = require('./lib/nostr.cjs')
test('generate private key from a mnemonic', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const privateKey = nip06.privateKeyFromSeedWords(mnemonic)
expect(privateKey).toEqual('c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2')
expect(privateKey).toEqual(
'c26cf31d8ba425b555ca27d00ca71b5008004f2f662470f8c8131822ec129fe2'
)
})
test('generate private key from a mnemonic and passphrase', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const passphrase = '123'
const privateKey = nip06.privateKeyFromSeedWords(mnemonic, passphrase)
expect(privateKey).toEqual('55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4')
expect(privateKey).toEqual(
'55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4'
)
})

View File

@@ -7,7 +7,10 @@ import {
} from '@scure/bip39'
import {HDKey} from '@scure/bip32'
export function privateKeyFromSeedWords(mnemonic: string, passphrase?: string): string {
export function privateKeyFromSeedWords(
mnemonic: string,
passphrase?: string
): string {
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
let privateKey = root.derive(`m/44'/1237'/0'/0/0`).privateKey
if (!privateKey) throw new Error('could not derive private key')

353
nip10.test.js Normal file
View File

@@ -0,0 +1,353 @@
/* eslint-env jest */
const {nip10} = require('./lib/nostr.cjs')
describe('parse NIP10-referenced events', () => {
test('legacy + a lot of events', () => {
let event = {
tags: [
[
'e',
'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'
],
[
'e',
'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'
],
[
'e',
'5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'
],
[
'e',
'49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4'
],
[
'e',
'567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976'
],
[
'e',
'090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051'
],
[
'e',
'89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d'
],
[
'p',
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'
],
[
'p',
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'
],
[
'p',
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'
]
]
}
expect(nip10.parse(event)).toEqual({
mentions: [
{
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
relays: []
},
{
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
relays: []
},
{
id: '49aff7ae6daeaaa2777931b90f9bb29f6cb01c5a3d7d88c8ba82d890f264afb4',
relays: []
},
{
id: '567b7c11f0fe582361e3cea6fcc7609a8942dfe196ee1b98d5604c93fbeea976',
relays: []
},
{
id: '090c037b2e399ee74d9f134758928948dd9154413ca1a1acb37155046e03a051',
relays: []
}
],
profiles: [
{
pubkey:
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
relays: []
},
{
pubkey:
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: []
},
{
pubkey:
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
relays: []
}
],
reply: {
id: '89f220b63465c93542b1a78caa3a952cf4f196e91a50596493c8093c533ebc4d',
relays: []
},
root: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: []
}
})
})
test('legacy + 3 events', () => {
let event = {
tags: [
[
'e',
'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'
],
[
'e',
'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'
],
[
'e',
'5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64'
],
[
'p',
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'
],
[
'p',
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'
],
[
'p',
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'
]
]
}
expect(nip10.parse(event)).toEqual({
mentions: [
{
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
relays: []
}
],
profiles: [
{
pubkey:
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
relays: []
},
{
pubkey:
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: []
},
{
pubkey:
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
relays: []
}
],
reply: {
id: '5e081ebb19153357d7c31e8a10b9ceeef29313f58dc8d701f66727fab02aef64',
relays: []
},
root: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: []
}
})
})
test('legacy + 2 events', () => {
let event = {
tags: [
[
'e',
'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c'
],
[
'e',
'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631'
],
[
'p',
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7'
],
[
'p',
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'
],
[
'p',
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0'
]
]
}
expect(nip10.parse(event)).toEqual({
mentions: [],
profiles: [
{
pubkey:
'77ce56f89d1228f7ff3743ce1ad1b254857b9008564727ebd5a1f317362f6ca7',
relays: []
},
{
pubkey:
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: []
},
{
pubkey:
'4ca4f5533e40da5e0508796d409e6bb35a50b26fc304345617ab017183d83ac0',
relays: []
}
],
reply: {
id: 'bbd72f0ae14374aa8fb166b483cfcf99b57d7f4cf1600ccbf17c350040834631',
relays: []
},
root: {
id: 'b857504288c18a15950dd05b9e8772c62ca6289d5aac373c0a8ee5b132e94e7c',
relays: []
}
})
})
test('legacy + 1 event', () => {
let event = {
tags: [
[
'e',
'9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590'
],
[
'p',
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec'
]
]
}
expect(nip10.parse(event)).toEqual({
mentions: [],
profiles: [
{
pubkey:
'534780e44da7b494485e85cd4cca6af4f6caa1627472432b6f2a4ece0e9e54ec',
relays: []
}
],
reply: undefined,
root: {
id: '9abbfd9b9ac5ecdab45d14b8bf8d746139ea039e931a1b376d19a239f1946590',
relays: []
}
})
})
test.todo('recommended + a lot of events')
test.todo('recommended + 3 events')
test.todo('recommended + 2 events')
test('recommended + 1 event', () => {
let event = {
tags: [
[
'p',
'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52',
'wss://relay.mostr.pub'
],
[
'p',
'003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6',
'wss://relay.mostr.pub'
],
[
'p',
'2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e',
'wss://relay.mostr.pub'
],
[
'p',
'44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e',
'wss://relay.mostr.pub'
],
[
'p',
'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
'wss://relay.mostr.pub'
],
[
'p',
'094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e',
'wss://relay.mostr.pub'
],
[
'p',
'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda',
'wss://relay.mostr.pub'
],
[
'e',
'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
'wss://relay.mostr.pub',
'reply'
],
['mostr', 'https://poa.st/objects/dc50684b-6364-4264-ab16-49f4622f05ea']
]
}
expect(nip10.parse(event)).toEqual({
mentions: [],
profiles: [
{
pubkey:
'a8c21fcd8aa1f4befba14d72fc7a012397732d30d8b3131af912642f3c726f52',
relays: ['wss://relay.mostr.pub']
},
{
pubkey:
'003d7fd21fd09ff7f6f63a75daf194dd99feefbe6919cc376b7359d5090aa9a6',
relays: ['wss://relay.mostr.pub']
},
{
pubkey:
'2f6fbe452edd3987d3c67f3b034c03ec5bcf4d054c521c3a954686f89f03212e',
relays: ['wss://relay.mostr.pub']
},
{
pubkey:
'44c7c74668ff222b0e0b30579c49fc6e22dafcdeaad091036c947f9856590f1e',
relays: ['wss://relay.mostr.pub']
},
{
pubkey:
'c5cf39149caebda4cdd61771c51f6ba91ef5645919004e5c4998a4ea69f00512',
relays: ['wss://relay.mostr.pub']
},
{
pubkey:
'094d44bb1e812696c57f57ad1c0c707812dedbe72c07e538b80639032c236a9e',
relays: ['wss://relay.mostr.pub']
},
{
pubkey:
'a1ba0ac9b6ec098f726a3c11ec654df4a32cbb84b5377e8788395e9c27d9ecda',
relays: ['wss://relay.mostr.pub']
}
],
reply: {
id: 'f9472913904ab7e9da008dcb2d85fd4af2d2993ada483d00c646d0c4481d031d',
relays: ['wss://relay.mostr.pub']
},
root: undefined
})
})
})

96
nip10.ts Normal file
View File

@@ -0,0 +1,96 @@
import type {Event} from './event'
import type {EventPointer, ProfilePointer} from './nip19'
export type NIP10Result = {
/**
* Pointer to the root of the thread.
*/
root: EventPointer | undefined
/**
* Pointer to a "parent" event that parsed event replies to (responded to).
*/
reply: EventPointer | undefined
/**
* Pointers to events which may or may not be in the reply chain.
*/
mentions: EventPointer[]
/**
* List of pubkeys that are involved in the thread in no particular order.
*/
profiles: ProfilePointer[]
}
export function parse(event: Pick<Event, 'tags'>): NIP10Result {
const result: NIP10Result = {
reply: undefined,
root: undefined,
mentions: [],
profiles: []
}
const eTags: string[][] = []
for (const tag of event.tags) {
if (tag[0] === 'e' && tag[1]) {
eTags.push(tag)
}
if (tag[0] === 'p' && tag[1]) {
result.profiles.push({
pubkey: tag[1],
relays: tag[2] ? [tag[2]] : []
})
}
}
for (let eTagIndex = 0; eTagIndex < eTags.length; eTagIndex++) {
const eTag = eTags[eTagIndex]
const [_, eTagEventId, eTagRelayUrl, eTagMarker] = eTag as [
string,
string,
undefined | string,
undefined | string
]
const eventPointer: EventPointer = {
id: eTagEventId,
relays: eTagRelayUrl ? [eTagRelayUrl] : []
}
const isFirstETag = eTagIndex === 0
const isLastETag = eTagIndex === eTags.length - 1
if (eTagMarker === 'root') {
result.root = eventPointer
continue
}
if (eTagMarker === 'reply') {
result.reply = eventPointer
continue
}
if (eTagMarker === 'mention') {
result.mentions.push(eventPointer)
continue
}
if (isFirstETag) {
result.root = eventPointer
continue
}
if (isLastETag) {
result.reply = eventPointer
continue
}
result.mentions.push(eventPointer)
}
return result
}

8
nip13.test.js Normal file
View File

@@ -0,0 +1,8 @@
/* eslint-env jest */
const {nip13} = require('./lib/nostr.cjs')
test('identifies proof-of-work difficulty', async () => {
const id = '000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358'
const difficulty = nip13.getPow(id)
expect(difficulty).toEqual(21)
})

42
nip13.ts Normal file
View File

@@ -0,0 +1,42 @@
import * as secp256k1 from '@noble/secp256k1'
/** Get POW difficulty from a Nostr hex ID. */
export function getPow(id: string): number {
return getLeadingZeroBits(secp256k1.utils.hexToBytes(id))
}
/**
* Get number of leading 0 bits. Adapted from nostream.
* https://github.com/Cameri/nostream/blob/fb6948fd83ca87ce552f39f9b5eb780ea07e272e/src/utils/proof-of-work.ts
*/
function getLeadingZeroBits(hash: Uint8Array): number {
let total: number, i: number, bits: number
for (i = 0, total = 0; i < hash.length; i++) {
bits = msb(hash[i])
total += bits
if (bits !== 8) {
break
}
}
return total
}
/**
* Adapted from nostream.
* https://github.com/Cameri/nostream/blob/fb6948fd83ca87ce552f39f9b5eb780ea07e272e/src/utils/proof-of-work.ts
*/
function msb(b: number) {
let n = 0
if (b === 0) {
return 8
}
// eslint-disable-next-line no-cond-assign
while (b >>= 1) {
n++
}
return 7 - n
}

View File

@@ -34,3 +34,78 @@ test('encode and decode nprofile', () => {
expect(data.relays).toContain(relays[0])
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 = [
'wss://relay.nostr.example.mydomain.example.com',
'wss://nostr.banana.com'
]
let naddr = nip19.naddrEncode({
pubkey: pk,
relays,
kind: 30023,
identifier: 'banana'
})
expect(naddr).toMatch(/naddr1\w+/)
let {type, data} = nip19.decode(naddr)
expect(type).toEqual('naddr')
expect(data.pubkey).toEqual(pk)
expect(data.relays).toContain(relays[0])
expect(data.relays).toContain(relays[1])
expect(data.kind).toEqual(30023)
expect(data.identifier).toEqual('banana')
})
test('decode naddr from habla.news', () => {
let {type, data} = nip19.decode(
'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5'
)
expect(type).toEqual('naddr')
expect(data.pubkey).toEqual(
'7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194'
)
expect(data.kind).toEqual(30023)
expect(data.identifier).toEqual('references')
})
test('decode naddr from go-nostr with different TLV ordering', () => {
let {type, data} = nip19.decode(
'naddr1qqrxyctwv9hxzq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqp65wqfwwaehxw309aex2mrp0yhxummnw3ezuetcv9khqmr99ekhjer0d4skjm3wv4uxzmtsd3jjucm0d5q3vamnwvaz7tmwdaehgu3wvfskuctwvyhxxmmd0zfmwx'
)
expect(type).toEqual('naddr')
expect(data.pubkey).toEqual(
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'
)
expect(data.relays).toContain(
'wss://relay.nostr.example.mydomain.example.com'
)
expect(data.relays).toContain('wss://nostr.banana.com')
expect(data.kind).toEqual(30023)
expect(data.identifier).toEqual('banana')
})
test('encode and decode nrelay', () => {
let url = "wss://relay.nostr.example"
let nrelay = nip19.nrelayEncode(url)
expect(nrelay).toMatch(/nrelay1\w+/)
let {type, data} = nip19.decode(nrelay)
expect(type).toEqual('nrelay')
expect(data).toEqual(url)
})

134
nip19.ts
View File

@@ -13,48 +13,99 @@ export type ProfilePointer = {
export type EventPointer = {
id: string // hex
relays?: string[]
author?: string
}
export function decode(nip19: string): {
type: string
data: ProfilePointer | EventPointer | string
} {
export type AddressPointer = {
identifier: string
pubkey: string
kind: number
relays?: string[]
}
export type DecodeResult =
| {type: 'nprofile'; data: ProfilePointer}
| {type: 'nrelay'; data: string}
| {type: 'nevent'; data: EventPointer}
| {type: 'naddr'; data: AddressPointer}
| {type: 'nsec'; data: string}
| {type: 'npub'; data: string}
| {type: 'note'; data: string}
export function decode(nip19: string): DecodeResult {
let {prefix, words} = bech32.decode(nip19, Bech32MaxSize)
let data = new Uint8Array(bech32.fromWords(words))
if (prefix === 'nprofile') {
let tlv = parseTLV(data)
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nprofile')
if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes')
switch (prefix) {
case 'nprofile': {
let tlv = parseTLV(data)
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nprofile')
if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes')
return {
type: 'nprofile',
data: {
pubkey: secp256k1.utils.bytesToHex(tlv[0][0]),
relays: tlv[1].map(d => utf8Decoder.decode(d))
return {
type: 'nprofile',
data: {
pubkey: secp256k1.utils.bytesToHex(tlv[0][0]),
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : []
}
}
}
}
case 'nevent': {
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')
if (prefix === 'nevent') {
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')
return {
type: 'nevent',
data: {
id: secp256k1.utils.bytesToHex(tlv[0][0]),
relays: tlv[1].map(d => utf8Decoder.decode(d))
return {
type: 'nevent',
data: {
id: secp256k1.utils.bytesToHex(tlv[0][0]),
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
author: tlv[2]?.[0]
? secp256k1.utils.bytesToHex(tlv[2][0])
: undefined
}
}
}
}
if (prefix === 'nsec' || prefix === 'npub' || prefix === 'note') {
return {type: prefix, data: secp256k1.utils.bytesToHex(data)}
}
case 'naddr': {
let tlv = parseTLV(data)
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for naddr')
if (!tlv[2]?.[0]) throw new Error('missing TLV 2 for naddr')
if (tlv[2][0].length !== 32) throw new Error('TLV 2 should be 32 bytes')
if (!tlv[3]?.[0]) throw new Error('missing TLV 3 for naddr')
if (tlv[3][0].length !== 4) throw new Error('TLV 3 should be 4 bytes')
throw new Error(`unknown prefix ${prefix}`)
return {
type: 'naddr',
data: {
identifier: utf8Decoder.decode(tlv[0][0]),
pubkey: secp256k1.utils.bytesToHex(tlv[2][0]),
kind: parseInt(secp256k1.utils.bytesToHex(tlv[3][0]), 16),
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : []
}
}
}
case 'nrelay': {
let tlv = parseTLV(data)
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nrelay')
return {
type: 'nrelay',
data: utf8Decoder.decode(tlv[0][0])
}
}
case 'nsec':
case 'npub':
case 'note':
return {type: prefix, data: secp256k1.utils.bytesToHex(data)}
default:
throw new Error(`unknown prefix ${prefix}`)
}
}
type TLV = {[t: number]: Uint8Array[]}
@@ -104,12 +155,35 @@ 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)
}
export function naddrEncode(addr: AddressPointer): string {
let kind = new ArrayBuffer(4)
new DataView(kind).setUint32(0, addr.kind, false)
let data = encodeTLV({
0: [utf8Encoder.encode(addr.identifier)],
1: (addr.relays || []).map(url => utf8Encoder.encode(url)),
2: [secp256k1.utils.hexToBytes(addr.pubkey)],
3: [new Uint8Array(kind)]
})
let words = bech32.toWords(data)
return bech32.encode('naddr', words, Bech32MaxSize)
}
export function nrelayEncode(url: string): string {
let data = encodeTLV({
0: [utf8Encoder.encode(url)]
})
let words = bech32.toWords(data)
return bech32.encode('nrelay', words, Bech32MaxSize)
}
function encodeTLV(tlv: TLV): Uint8Array {
let entries: Uint8Array[] = []

42
nip21.test.js Normal file
View File

@@ -0,0 +1,42 @@
/* eslint-env jest */
const {nip21} = require('./lib/nostr.cjs')
test('test', () => {
expect(
nip21.test(
'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6'
)
).toBe(true)
expect(
nip21.test(
'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
)
).toBe(true)
expect(
nip21.test(
' nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6'
)
).toBe(false)
expect(nip21.test('nostr:')).toBe(false)
expect(
nip21.test(
'nostr:npub108pv4cg5ag52nQq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6'
)
).toBe(false)
expect(nip21.test('gggggg')).toBe(false)
})
test('parse', () => {
const result = nip21.parse(
'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
)
expect(result).toEqual({
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
decoded: {
type: 'note',
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b'
}
})
})

41
nip21.ts Normal file
View File

@@ -0,0 +1,41 @@
import * as nip19 from './nip19'
import * as nip21 from './nip21'
/**
* Bech32 regex.
* @see https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki#bech32
*/
export const BECH32_REGEX =
/[\x21-\x7E]{1,83}1[023456789acdefghjklmnpqrstuvwxyz]{6,}/
/** Nostr URI regex, eg `nostr:npub1...` */
export const NOSTR_URI_REGEX = new RegExp(`nostr:(${BECH32_REGEX.source})`)
/** Test whether the value is a Nostr URI. */
export function test(value: unknown): value is `nostr:${string}` {
return (
typeof value === 'string' &&
new RegExp(`^${NOSTR_URI_REGEX.source}$`).test(value)
)
}
/** Parsed Nostr URI data. */
export interface NostrURI {
/** Full URI including the `nostr:` protocol. */
uri: `nostr:${string}`
/** The bech32-encoded data (eg `npub1...`). */
value: string
/** Decoded bech32 string, according to NIP-19. */
decoded: nip19.DecodeResult
}
/** Parse and decode a Nostr URI. */
export function parse(uri: string): NostrURI {
const match = uri.match(new RegExp(`^${nip21.NOSTR_URI_REGEX.source}$`))
if (!match) throw new Error(`Invalid Nostr URI: ${uri}`)
return {
uri: match[0] as `nostr:${string}`,
value: match[1],
decoded: nip19.decode(match[1])
}
}

49
nip27.test.js Normal file
View File

@@ -0,0 +1,49 @@
/* eslint-env jest */
const {nip27} = require('./lib/nostr.cjs')
test('matchAll', () => {
const result = nip27.matchAll(
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
)
expect([...result]).toEqual([
{
uri: 'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
value: 'npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6',
decoded: {
type: 'npub',
data: '79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6'
},
start: 6,
end: 75
},
{
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
decoded: {
type: 'note',
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b'
},
start: 78,
end: 147
}
])
})
test('replaceAll', () => {
const content =
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
const result = nip27.replaceAll(content, ({decoded, value}) => {
switch (decoded.type) {
case 'npub':
return '@alex'
case 'note':
return '!1234'
default:
return value
}
})
expect(result).toEqual('Hello @alex!\n\n!1234')
})

63
nip27.ts Normal file
View File

@@ -0,0 +1,63 @@
import * as nip19 from './nip19'
import * as nip21 from './nip21'
/** Regex to find NIP-21 URIs inside event content. */
export const regex = () =>
new RegExp(`\\b${nip21.NOSTR_URI_REGEX.source}\\b`, 'g')
/** Match result for a Nostr URI in event content. */
export interface NostrURIMatch extends nip21.NostrURI {
/** Index where the URI begins in the event content. */
start: number
/** Index where the URI ends in the event content. */
end: number
}
/** Find and decode all NIP-21 URIs. */
export function * matchAll(content: string): Iterable<NostrURIMatch> {
const matches = content.matchAll(regex())
for (const match of matches) {
const [uri, value] = match
yield {
uri: uri as `nostr:${string}`,
value,
decoded: nip19.decode(value),
start: match.index!,
end: match.index! + uri.length
}
}
}
/**
* Replace all occurrences of Nostr URIs in the text.
*
* WARNING: using this on an HTML string is potentially unsafe!
*
* @example
* ```ts
* nip27.replaceAll(event.content, ({ decoded, value }) => {
* switch(decoded.type) {
* case 'npub':
* return renderMention(decoded)
* case 'note':
* return renderNote(decoded)
* default:
* return value
* }
* })
* ```
*/
export function replaceAll(
content: string,
replacer: (match: nip21.NostrURI) => string
): string {
return content.replaceAll(regex(), (uri, value) => {
return replacer({
uri: uri as `nostr:${string}`,
value,
decoded: nip19.decode(value)
})
})
}

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
}
}

27
nip42.test.js Normal file
View File

@@ -0,0 +1,27 @@
/* eslint-env jest */
require('websocket-polyfill')
const {
relayInit,
generatePrivateKey,
finishEvent,
nip42
} = require('./lib/nostr.cjs')
test('auth flow', done => {
const relay = relayInit('wss://nostr.kollider.xyz')
relay.connect()
const sk = generatePrivateKey()
relay.on('auth', async challenge => {
await expect(
nip42.authenticate({
challenge,
relay,
sign: e => finishEvent(e, sk)
})
).rejects.toBeTruthy()
relay.close()
done()
})
})

42
nip42.ts Normal file
View File

@@ -0,0 +1,42 @@
import {EventTemplate, Event, Kind} from './event'
import {Relay} from './relay'
/**
* Authenticate via NIP-42 flow.
*
* @example
* const sign = window.nostr.signEvent
* relay.on('auth', challenge =>
* authenticate({ relay, sign, challenge })
* )
*/
export const authenticate = async ({
challenge,
relay,
sign
}: {
challenge: string
relay: Relay
sign: (e: EventTemplate) => Promise<Event>
}): Promise<void> => {
const e: EventTemplate = {
kind: Kind.ClientAuth,
created_at: Math.floor(Date.now() / 1000),
tags: [
['relay', relay.url],
['challenge', challenge]
],
content: ''
}
const pub = relay.auth(await sign(e))
return new Promise((resolve, reject) => {
pub.on('ok', function ok() {
pub.off('ok', ok)
resolve()
})
pub.on('failed', function fail(reason: string) {
pub.off('failed', fail)
reject(reason)
})
})
}

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')
})
})

138
nip57.ts Normal file
View File

@@ -0,0 +1,138 @@
import {bech32} from '@scure/base'
import {Event, EventTemplate, validateEvent, verifySignature} from './event'
import {utf8Decoder} from './utils'
var _fetch: any
try {
_fetch = fetch
} catch {}
export function useFetchImplementation(fetchImplementation: any) {
_fetch = fetchImplementation
}
export async function getZapEndpoint(metadata: Event): Promise<null | string> {
try {
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)
} else if (lud16) {
let [name, domain] = lud16.split('@')
lnurl = `https://${domain}/.well-known/lnurlp/${name}`
} else {
return null
}
let res = await _fetch(lnurl)
let body = await res.json()
if (body.allowsNostr && body.nostrPubkey) {
return body.callback
}
} catch (err) {
/*-*/
}
return null
}
export function makeZapRequest({
profile,
event,
amount,
relays,
comment = ''
}: {
profile: string
event: string | null
amount: number
comment: string
relays: string[]
}): EventTemplate {
if (!amount) throw new Error('amount not given')
if (!profile) throw new Error('profile not given')
let zr = {
kind: 9734,
created_at: Math.round(Date.now() / 1000),
content: comment,
tags: [
['p', profile],
['amount', amount.toString()],
['relays', ...relays]
]
}
if (event) {
zr.tags.push(['e', event])
}
return zr
}
export function validateZapRequest(zapRequestString: string): string | null {
let zapRequest: Event
try {
zapRequest = JSON.parse(zapRequestString)
} catch (err) {
return 'Invalid zap request JSON.'
}
if (!validateEvent(zapRequest))
return 'Zap request is not a valid Nostr event.'
if (!verifySignature(zapRequest)) return 'Invalid signature on zap request.'
let p = zapRequest.tags.find(([t, v]) => t === 'p' && v)
if (!p) return "Zap request doesn't have a 'p' tag."
if (!p[1].match(/^[a-f0-9]{64}$/))
return "Zap request 'p' tag is not valid hex."
let e = zapRequest.tags.find(([t, v]) => t === 'e' && v)
if (e && !e[1].match(/^[a-f0-9]{64}$/))
return "Zap request 'e' tag is not valid hex."
let relays = zapRequest.tags.find(([t, v]) => t === 'relays' && v)
if (!relays) return "Zap request doesn't have a 'relays' tag."
return null
}
export function makeZapReceipt({
zapRequest,
preimage,
bolt11,
paidAt
}: {
zapRequest: string
preimage: string | null
bolt11: string
paidAt: Date
}): EventTemplate {
let zr: Event = JSON.parse(zapRequest)
let tagsFromZapRequest = zr.tags.filter(
([t]) => t === 'e' || t === 'p' || t === 'a'
)
let zap = {
kind: 9735,
created_at: Math.round(paidAt.getTime() / 1000),
content: '',
tags: [
...tagsFromZapRequest,
['bolt11', bolt11],
['description', zapRequest]
]
}
if (preimage) {
zap.tags.push(['preimage', preimage])
}
return zap
}

View File

@@ -1,19 +1,28 @@
{
"name": "nostr-tools",
"version": "1.3.1",
"version": "1.10.1",
"description": "Tools for making a Nostr client.",
"repository": {
"type": "git",
"url": "https://github.com/fiatjaf/nostr-tools.git"
"url": "https://github.com/nbd-wtf/nostr-tools.git"
},
"files": [
"./lib/**/*"
],
"types": "./lib/index.d.ts",
"main": "lib/nostr.cjs.js",
"module": "lib/nostr.esm.js",
"module": "lib/esm/nostr.mjs",
"exports": {
"import": "./lib/esm/nostr.mjs",
"require": "./lib/nostr.cjs.js"
},
"license": "Unlicense",
"dependencies": {
"@noble/hashes": "^0.5.7",
"@noble/secp256k1": "^1.7.0",
"@scure/base": "^1.1.1",
"@scure/bip32": "^1.1.1",
"@scure/bip39": "^1.1.0"
"@noble/hashes": "1.2.0",
"@noble/secp256k1": "1.7.1",
"@scure/base": "1.1.1",
"@scure/bip32": "1.1.4",
"@scure/bip39": "1.1.1"
},
"keywords": [
"decentralization",
@@ -22,21 +31,27 @@
"client",
"nostr"
],
"scripts": {
"build": "node build",
"format": "prettier --plugin-search-dir . --write .",
"test": "node build && jest"
},
"devDependencies": {
"@types/node": "^18.0.3",
"@typescript-eslint/eslint-plugin": "^5.46.1",
"@typescript-eslint/parser": "^5.46.1",
"@types/node": "^18.13.0",
"@typescript-eslint/eslint-plugin": "^5.51.0",
"@typescript-eslint/parser": "^5.51.0",
"esbuild": "0.16.9",
"esbuild-plugin-alias": "^0.2.1",
"eslint": "^8.30.0",
"eslint": "^8.33.0",
"eslint-plugin-babel": "^5.3.1",
"esm-loader-typescript": "^1.0.1",
"esm-loader-typescript": "^1.0.3",
"events": "^3.3.0",
"jest": "^29.3.1",
"node-fetch": "2",
"ts-jest": "^29.0.3",
"jest": "^29.4.2",
"node-fetch": "^2.6.9",
"prettier": "^2.8.4",
"ts-jest": "^29.0.5",
"tsd": "^0.22.0",
"typescript": "^4.9.4",
"typescript": "^4.9.5",
"websocket-polyfill": "^0.0.3"
}
}

View File

@@ -12,15 +12,20 @@ const {
let pool = new SimplePool()
let relays = [
'wss://nostr-dev.wellorder.net/',
'wss://relay.damus.io/',
'wss://relay.nostr.bg/',
'wss://nostr.fmt.wiz.biz/',
'wss://relay.nostr.band/',
'wss://nostr.zebedee.cloud/'
'wss://nos.lol/'
]
afterAll(async () => {
await pool.close([...relays, 'wss://nostr-relay.untethr.me'])
afterAll(() => {
pool.close([
...relays,
'wss://nostr.wine',
'wss://offchain.pub',
'wss://eden.nostr.land'
])
})
test('removing duplicates when querying', async () => {
@@ -120,4 +125,9 @@ test('list()', async () => {
.reduce((acc, n) => (acc.indexOf(n) !== -1 ? acc : [...acc, n]), [])
.length
)
let relaysForAllEvents = events
.map(event => pool.seenOn(event.id))
.reduce((acc, n) => acc.concat(n), [])
expect(relaysForAllEvents.length).toBeGreaterThanOrEqual(events.length)
})

136
pool.ts
View File

@@ -2,78 +2,94 @@ import {Relay, relayInit} from './relay'
import {normalizeURL} from './utils'
import {Filter} from './filter'
import {Event} from './event'
import {SubscriptionOptions, Sub, Pub} from './relay'
import {SubscriptionOptions, Sub, Pub, CountPayload} from './relay'
export class SimplePool {
private _conn: {[url: string]: Relay}
private _seenOn: {[id: string]: Set<string>} = {} // a map of all events we've seen in each relay
constructor(defaultRelays: string[] = []) {
private eoseSubTimeout: number
private getTimeout: number
constructor(options: {eoseSubTimeout?: number; getTimeout?: number} = {}) {
this._conn = {}
defaultRelays.forEach(this.ensureRelay)
this.eoseSubTimeout = options.eoseSubTimeout || 3400
this.getTimeout = options.getTimeout || 3400
}
async close(relays: string[]): Promise<void> {
await Promise.all(
relays.map(async url => {
let relay = this._conn[normalizeURL(url)]
if (relay) await relay.close()
})
)
close(relays: string[]): void {
relays.forEach(url => {
let relay = this._conn[normalizeURL(url)]
if (relay) relay.close()
})
}
async ensureRelay(url: string): Promise<Relay> {
const nm = normalizeURL(url)
const existing = this._conn[nm]
if (existing) return existing
const relay = relayInit(nm)
this._conn[nm] = relay
if (!this._conn[nm]) {
this._conn[nm] = relayInit(nm, {
getTimeout: this.getTimeout * 0.9,
listTimeout: this.getTimeout * 0.9
})
}
const relay = this._conn[nm]
await relay.connect()
return relay
}
sub(relays: string[], filters: Filter[], opts?: SubscriptionOptions): Sub {
let _knownIds: Set<string> = new Set()
let modifiedOpts = opts || {}
modifiedOpts.alreadyHaveEvent = id => _knownIds.has(id)
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
return _knownIds.has(id)
}
let subs: Sub[] = []
let eventListeners: Set<(event: Event) => void> = new Set()
let eventListeners: Set<any> = new Set()
let eoseListeners: Set<() => void> = new Set()
let eosesMissing = relays.length
let eoseSent = false
let eoseTimeout = setTimeout(() => {
eoseSent = true
for (let cb of eoseListeners.values()) {
cb()
}
}, 2400)
for (let cb of eoseListeners.values()) cb()
}, this.eoseSubTimeout)
relays.forEach(async relay => {
let r = await this.ensureRelay(relay)
let r
try {
r = await this.ensureRelay(relay)
} catch (err) {
handleEose()
return
}
if (!r) return
let s = r.sub(filters, modifiedOpts)
s.on('event', (event: Event) => {
_knownIds.add(event.id as string)
for (let cb of eventListeners.values()) {
cb(event)
}
for (let cb of eventListeners.values()) cb(event)
})
s.on('eose', () => {
if (eoseSent) return
handleEose()
})
subs.push(s)
function handleEose() {
eosesMissing--
if (eosesMissing === 0) {
clearTimeout(eoseTimeout)
for (let cb of eoseListeners.values()) {
cb()
}
for (let cb of eoseListeners.values()) cb()
}
})
subs.push(s)
}
})
let greaterSub: Sub = {
@@ -87,12 +103,15 @@ export class SimplePool {
on(type, cb) {
if (type === 'event') {
eventListeners.add(cb)
} else if (type === 'eose') eoseListeners.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>)
}
}
@@ -109,7 +128,7 @@ export class SimplePool {
let timeout = setTimeout(() => {
sub.unsub()
resolve(null)
}, 1500)
}, this.getTimeout)
sub.on('event', (event: Event) => {
resolve(event)
clearTimeout(timeout)
@@ -139,21 +158,42 @@ export class SimplePool {
})
}
publish(relays: string[], event: Event): Pub[] {
return relays.map(relay => {
let r = this._conn[normalizeURL(relay)]
if (!r) return badPub(relay)
let s = r.publish(event)
return s
publish(relays: string[], event: Event): Pub {
const pubPromises: Promise<Pub>[] = relays.map(async relay => {
let r
try {
r = await this.ensureRelay(relay)
return r.publish(event)
} catch (_) {
return {on() {}, off() {}}
}
})
}
}
function badPub(relay: string): Pub {
return {
on(typ, cb) {
if (typ === 'failed') cb(`relay ${relay} not connected`)
},
off() {}
const callbackMap = new Map()
return {
on(type, cb) {
relays.forEach(async (relay, i) => {
let pub = await pubPromises[i]
let callback = () => cb(relay)
callbackMap.set(cb, callback)
pub.on(type, callback)
})
},
off(type, cb) {
relays.forEach(async (_, i) => {
let callback = callbackMap.get(cb)
if (callback) {
let pub = await pubPromises[i]
pub.off(type, callback)
}
})
}
}
}
seenOn(id: string): string[] {
return Array.from(this._seenOn[id]?.values?.() || [])
}
}

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] = tag[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

@@ -9,14 +9,14 @@ const {
signEvent
} = require('./lib/nostr.cjs')
let relay = relayInit('wss://nostr-dev.wellorder.net/')
let relay = relayInit('wss://relay.damus.io/')
beforeAll(() => {
relay.connect()
})
afterAll(async () => {
await relay.close()
afterAll(() => {
relay.close()
})
test('connectivity', () => {

291
relay.ts
View File

@@ -4,61 +4,91 @@ 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>
auth: (challenge: string) => void | Promise<void>
}
export type CountPayload = {
count: number
}
type SubEvent = {
event: (event: Event) => void | Promise<void>
count: (payload: CountPayload) => void | Promise<void>
eose: () => void | Promise<void>
}
export type Relay = {
url: string
status: number
connect: () => Promise<void>
close: () => Promise<void>
close: () => void
sub: (filters: Filter[], opts?: SubscriptionOptions) => Sub
list: (filters: Filter[], opts?: SubscriptionOptions) => Promise<Event[]>
get: (filter: Filter, opts?: SubscriptionOptions) => Promise<Event | null>
count: (
filters: Filter[],
opts?: SubscriptionOptions
) => Promise<CountPayload | null>
publish: (event: Event) => Pub
on: (type: RelayEvent, cb: any) => void
off: (type: RelayEvent, cb: any) => void
auth: (event: Event) => Pub
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' | 'seen' | 'failed', cb: any) => void
off: (type: 'ok' | 'seen' | 'failed', cb: any) => void
on: (type: 'ok' | 'failed', cb: any) => void
off: (type: 'ok' | 'failed', cb: any) => void
}
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 = {
skipVerification?: boolean
alreadyHaveEvent?: null | ((id: string) => boolean)
id?: string
verb?: 'REQ' | 'COUNT'
skipVerification?: boolean
alreadyHaveEvent?: null | ((id: string, relay: string) => boolean)
}
export function relayInit(url: string): Relay {
const newListeners = (): {[TK in keyof RelayEvent]: RelayEvent[TK][]} => ({
connect: [],
disconnect: [],
error: [],
notice: [],
auth: []
})
export function relayInit(
url: string,
options: {
getTimeout?: number
listTimeout?: number
countTimeout?: number
} = {}
): Relay {
let {listTimeout = 3000, getTimeout = 3000, countTimeout = 3000} = options
var ws: WebSocket
var resolveClose: () => void
var setOpen: (value: PromiseLike<void> | void) => void
var untilOpen = new Promise<void>(resolve => {
setOpen = resolve
})
var openSubs: {[id: string]: {filters: Filter[]} & SubscriptionOptions} = {}
var listeners: {
connect: Array<() => void>
disconnect: Array<() => void>
error: Array<() => void>
notice: Array<(msg: string) => void>
} = {
connect: [],
disconnect: [],
error: [],
notice: []
}
var listeners = newListeners()
var subListeners: {
[subid: string]: {
event: Array<(event: Event) => void>
eose: Array<() => void>
}
[subid: string]: {[TK in keyof SubEvent]: SubEvent[TK][]}
} = {}
var pubListeners: {
[eventid: string]: {
@@ -68,22 +98,28 @@ export function relayInit(url: string): Relay {
}
} = {}
var connectionPromise: Promise<void> | undefined
async function connectRelay(): Promise<void> {
return new Promise((resolve, reject) => {
ws = new WebSocket(url)
if (connectionPromise) return connectionPromise
connectionPromise = new Promise((resolve, reject) => {
try {
ws = new WebSocket(url)
} catch (err) {
reject(err)
}
ws.onopen = () => {
listeners.connect.forEach(cb => cb())
setOpen()
resolve()
}
ws.onerror = () => {
connectionPromise = undefined
listeners.error.forEach(cb => cb())
reject()
}
ws.onclose = async () => {
connectionPromise = undefined
listeners.disconnect.forEach(cb => cb())
resolveClose && resolveClose()
}
let incomingMessageQueue: string[] = []
@@ -112,7 +148,7 @@ export function relayInit(url: string): Relay {
if (
so &&
so.alreadyHaveEvent &&
so.alreadyHaveEvent(getHex64(json, 'id'))
so.alreadyHaveEvent(getHex64(json, 'id'), url)
) {
return
}
@@ -125,7 +161,7 @@ export function relayInit(url: string): Relay {
// will naturally be caught by the encompassing try..catch block
switch (data[0]) {
case 'EVENT':
case 'EVENT': {
let id = data[1]
let event = data[2]
if (
@@ -138,40 +174,70 @@ export function relayInit(url: string): Relay {
;(subListeners[id]?.event || []).forEach(cb => cb(event))
}
return
}
case 'COUNT':
let id = data[1]
let payload = data[2]
if (openSubs[id]) {
;(subListeners[id]?.count || []).forEach(cb => cb(payload))
}
return
case 'EOSE': {
let id = data[1]
;(subListeners[id]?.eose || []).forEach(cb => cb())
if (id in subListeners) {
subListeners[id].eose.forEach(cb => cb())
subListeners[id].eose = [] // 'eose' only happens once per sub, so stop listeners here
}
return
}
case 'OK': {
let id: string = data[1]
let ok: boolean = data[2]
let reason: string = data[3] || ''
if (ok) pubListeners[id]?.ok.forEach(cb => cb())
else pubListeners[id]?.failed.forEach(cb => cb(reason))
if (id in pubListeners) {
if (ok) pubListeners[id].ok.forEach(cb => cb())
else pubListeners[id].failed.forEach(cb => cb(reason))
pubListeners[id].ok = [] // 'ok' only happens once per pub, so stop listeners here
pubListeners[id].failed = []
}
return
}
case 'NOTICE':
let notice = data[1]
listeners.notice.forEach(cb => cb(notice))
return
case 'AUTH': {
let challenge = data[1]
listeners.auth?.forEach(cb => cb(challenge))
return
}
}
} catch (err) {
return
}
}
})
return connectionPromise
}
function connected() {
return ws?.readyState === 1
}
async function connect(): Promise<void> {
if (ws?.readyState && ws.readyState === 1) return // ws already open
if (connected()) return // ws already open
await connectRelay()
}
async function trySend(params: [string, ...any]) {
let msg = JSON.stringify(params)
await untilOpen
if (!connected()) {
await new Promise(resolve => setTimeout(resolve, 1000))
if (!connected()) {
return
}
}
try {
ws.send(msg)
} catch (err) {
@@ -182,6 +248,7 @@ export function relayInit(url: string): Relay {
const sub = (
filters: Filter[],
{
verb = 'REQ',
skipVerification = false,
alreadyHaveEvent = null,
id = Math.random().toString().slice(2)
@@ -195,7 +262,7 @@ export function relayInit(url: string): Relay {
skipVerification,
alreadyHaveEvent
}
trySend(['REQ', subid, ...filters])
trySend([verb, subid, ...filters])
return {
sub: (newFilters, newOpts = {}) =>
@@ -209,14 +276,21 @@ export function relayInit(url: string): Relay {
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: [],
count: [],
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)
@@ -224,16 +298,46 @@ export function relayInit(url: string): Relay {
}
}
function _publishEvent(event: Event, type: string) {
if (!event.id) throw new Error(`event ${event} has no id`)
let id = event.id
trySend([type, event])
return {
on: (type: 'ok' | 'failed', cb: any) => {
pubListeners[id] = pubListeners[id] || {
ok: [],
failed: []
}
pubListeners[id][type].push(cb)
},
off: (type: 'ok' | 'failed', cb: any) => {
let listeners = pubListeners[id]
if (!listeners) return
let idx = listeners[type].indexOf(cb)
if (idx >= 0) listeners[type].splice(idx, 1)
}
}
}
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)
},
@@ -244,7 +348,7 @@ export function relayInit(url: string): Relay {
let timeout = setTimeout(() => {
s.unsub()
resolve(events)
}, 1500)
}, listTimeout)
s.on('eose', () => {
s.unsub()
clearTimeout(timeout)
@@ -260,75 +364,40 @@ export function relayInit(url: string): Relay {
let timeout = setTimeout(() => {
s.unsub()
resolve(null)
}, 1500)
}, getTimeout)
s.on('event', (event: Event) => {
s.unsub()
clearTimeout(timeout)
resolve(event)
})
}),
publish(event: Event): Pub {
if (!event.id) throw new Error(`event ${event} has no id`)
let id = event.id
var sent = false
var mustMonitor = false
trySend(['EVENT', event])
.then(() => {
sent = true
if (mustMonitor) {
startMonitoring()
mustMonitor = false
}
count: (filters: Filter[]): Promise<CountPayload | null> =>
new Promise(resolve => {
let s = sub(filters, {...sub, verb: 'COUNT'})
let timeout = setTimeout(() => {
s.unsub()
resolve(null)
}, countTimeout)
s.on('count', (event: CountPayload) => {
s.unsub()
clearTimeout(timeout)
resolve(event)
})
.catch(() => {})
const startMonitoring = () => {
let monitor = sub([{ids: [id]}], {
id: `monitor-${id.slice(0, 5)}`
})
let willUnsub = setTimeout(() => {
;(pubListeners[id]?.failed || []).forEach(cb =>
cb('event not seen after 5 seconds')
)
monitor.unsub()
}, 5000)
monitor.on('event', () => {
clearTimeout(willUnsub)
;(pubListeners[id]?.seen || []).forEach(cb => cb())
})
}
return {
on: (type: 'ok' | 'seen' | 'failed', cb: any) => {
pubListeners[id] = pubListeners[id] || {
ok: [],
seen: [],
failed: []
}
pubListeners[id][type].push(cb)
if (type === 'seen') {
if (sent) startMonitoring()
else mustMonitor = true
}
},
off: (type: 'ok' | 'seen' | 'failed', cb: any) => {
let listeners = pubListeners[id]
if (!listeners) return
let idx = listeners[type].indexOf(cb)
if (idx >= 0) listeners[type].splice(idx, 1)
}
}
}),
publish(event): Pub {
return _publishEvent(event, 'EVENT')
},
auth(event): Pub {
return _publishEvent(event, 'AUTH')
},
connect,
close(): Promise<void> {
if (ws.readyState > 1) return Promise.resolve()
ws.close()
return new Promise<void>(resolve => {
resolveClose = resolve
})
close(): void {
listeners = newListeners()
subListeners = {}
pubListeners = {}
if (ws.readyState === WebSocket.OPEN) {
ws?.close()
}
},
get status() {
return ws?.readyState ?? 3

View File

@@ -9,7 +9,7 @@
"skipLibCheck": true,
"esModuleInterop": true,
"emitDeclarationOnly": true,
"outDir": "dist",
"outDir": "lib",
"rootDir": "."
}
}

3989
yarn.lock Normal file

File diff suppressed because it is too large Load Diff