Compare commits

...

272 Commits

Author SHA1 Message Date
fiatjaf
17590cce91 tag v1.13.1 2023-07-23 10:15:00 -03:00
Pavan Joshi
ee9f37e192 Update package.json to upgrade scure/bip39 (#254)
* Update package.json to upgrade scure/bip39

scure/bip39 1.2.0 causing problem of "Can't resolve '@scure/bip39/wordlists/english' ... because it was resolved as fully specified "

* Update package.json
2023-07-23 09:41:53 -03:00
fiatjaf
c1848d78a0 tag v1.13.0 2023-07-17 13:14:10 -03:00
Dolu
81776ba811 fix(nip98): add export 2023-07-17 11:51:12 -03:00
Dolu
915d6d729b feat(nip98): add getToken and validateToken 2023-07-17 11:51:12 -03:00
jiftechnify
1a23f5ee01 keep up with the latest specs for since/until filter 2023-07-15 16:08:31 -03:00
Alex Gleason
fec40490a2 Merge pull request #249 from alexgleason/fix-nip27-type
Fix nip27 type
2023-07-13 11:02:33 -05:00
Alex Gleason
bb3e41bb89 Also remove failing nip05 test due to server down (this should be mocked) 2023-07-13 10:57:30 -05:00
Alex Gleason
27b971eef3 Fix nip27 type 2023-07-13 10:48:22 -05:00
futpib
0041008b22 Add an option to disable seenOn 2023-07-06 16:38:30 -03:00
Perlover
ae5bf4c72c Fix with "wordlists/english.js"
Details are here: https://github.com/nbd-wtf/nostr-tools/issues/25
2023-07-04 15:07:25 -03:00
Alex Gleason
75fc836cf6 Merge pull request #241 from alexgleason/nip19-cool-type
nip19: use template literal types
2023-07-02 12:22:04 -05:00
Alex Gleason
70b025b8da nip19: use a DRY type 2023-07-01 22:44:04 -05:00
Alex Gleason
c9bc702d90 nip19: use template literal types 2023-07-01 21:28:01 -05:00
Alex Gleason
7652318185 Fix nip27 test 2023-06-29 16:44:44 -03:00
fiatjaf
d81a2444b3 release v1.12.1 2023-06-29 15:25:32 -03:00
Alex Gleason
7507943253 Fix nip27.matchAll crash on invalid nip19 2023-06-29 15:24:25 -03:00
PMK
b9a7f814aa Update Kind for ProfileBadge and BadgeDefinition 2023-06-20 15:40:55 -03:00
fiatjaf
0e364701da link to ndk and snort system. 2023-06-17 08:37:20 -03:00
fiatjaf
a55fb8465f mergeFilters() 2023-06-09 23:00:57 -03:00
fiatjaf
472a01af6a fix infinite loop bug caused by malformed TLVs on nip19. 2023-06-08 10:33:28 -03:00
fiatjaf_
bb5acfc197 Merge pull request #214 from Egge7/messagequeue
Replace array list queue with a linked list one
2023-05-20 08:20:56 -03:00
fiatjaf
1c6f39e4ae v1.11.1 2023-05-18 17:32:57 -03:00
Egge
5b15237b95 replace ArrayList with Queue 2023-05-16 17:16:38 +02:00
Egge
4184609a00 added test cases for MessageQueue 2023-05-13 09:44:52 +02:00
Egge
97287cad74 comply with eslint config 2023-05-13 09:44:41 +02:00
Egge
fa21f71ab5 added queue classes 2023-05-12 23:20:03 +02:00
Alex Gleason
08885ab8da Refactor imports: use file extension, improve tree shaking, update tests 2023-05-12 17:03:41 -03:00
Egge
9f896479d0 update package.json to export declarations 2023-05-10 21:22:11 -03:00
Alex Gleason
82caa2aad9 Use buildEvent function in more places 2023-05-10 21:20:27 -03:00
Alex Gleason
67a8ee23ce Don't build before test (??) 2023-05-10 21:20:27 -03:00
Alex Gleason
18e8227123 Convert all tests to TypeScript 2023-05-10 21:20:27 -03:00
Alex Gleason
64caef9cda Convert nip05 test to typescript 2023-05-10 21:20:27 -03:00
Alex Gleason
6a07d2d9d3 nip05: fix not calling underscored fetch 2023-05-07 21:18:22 -03:00
Alex Gleason
341ccc5ac5 nip05: move NIP05Result to the bottom, add another test 2023-05-07 21:18:22 -03:00
Alex Gleason
d2a9af2586 nip05 refactoring 2023-05-07 21:18:22 -03:00
fiatjaf
5d92be05bb run prettier on tests. 2023-05-07 21:18:12 -03:00
Paul Miller
03cc18d53b bring back @noble/curves instead of @noble/secp256k1.
fixes https://github.com/nbd-wtf/nostr-tools/issues/196#issuecomment-1537549606
2023-05-07 21:16:48 -03:00
futpib
ac7598b5e3 Fix reposts without p tag not parsed 2023-05-07 08:52:18 -03:00
futpib
424449c773 Add NIP-18 utils 2023-05-07 08:52:18 -03:00
Alex Gleason
ab6abe6815 Improve types of filter.ts 2023-05-06 21:00:25 -03:00
Alex Gleason
30fd6b6215 nip57: use Kind enum instead of using the number directly 2023-05-06 21:00:25 -03:00
Alex Gleason
8a53b3b8b3 Improve event types 2023-05-06 21:00:25 -03:00
Alex Gleason
d0bd599ce8 Infer relay event types from filter 2023-05-06 20:59:39 -03:00
Alex Gleason
1cbb62e6b9 Move BECH32_REGEX to nip19.ts 2023-05-03 17:12:39 -03:00
Luka Dover
977316915b Fix subtle inconsistency with NIP-04 in the decryption example
Sender's pubkey was incorrectly searched for in the `p` tag, where receiver's pubkey is found; use `event.pubkey` instead.
2023-05-03 09:44:03 -03:00
Alex Gleason
dd8f555094 Make Filter a generic type accepting Kind 2023-05-02 22:35:04 -03:00
eosxx
87f5ea4291 test(event): add test for getBlankEvent 2023-05-01 17:02:14 -03:00
eosxx
595ae21baf feat(event): getBlankEvent can accept a kind 2023-05-01 17:02:14 -03:00
Alex Gleason
9fa554ca8e Make Event a generic type accepting Kind 2023-04-30 09:26:40 -03:00
eosxx
1647601727 fix: check crypto and webcrypto 2023-04-28 05:56:25 -03:00
eosxx
b66ca1787a fix(nip04): crypto.subtle is undefined 2023-04-28 05:56:25 -03:00
fiatjaf_
278cdda9c2 Merge pull request #195 from alexgleason/get-signature 2023-04-24 07:28:06 -03:00
Alex Gleason
552530fa3f Add back deprecated signEvent function with a warning 2023-04-24 01:22:12 -05:00
futpib
13e9b4aa3e Add NIP-25 utils 2023-04-23 20:19:52 -03:00
Alex Gleason
9a3e05ce5f Rename signEvent to getSignature 2023-04-23 11:13:15 -05:00
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
fiatjaf
e9acc59809 just publish. 2023-02-09 12:09:16 -03:00
fiatjaf
18fe9637b9 do not run tests on tag pushes. 2023-02-09 12:08:50 -03:00
fiatjaf
ff3bf4a51c improvements and fixes on pool. 2023-02-09 12:05:31 -03:00
fiatjaf
7ff97b5488 list() and get() methods. 2023-02-08 16:37:53 -03:00
fiatjaf
df169ea42b fix just. 2023-02-08 15:29:05 -03:00
fiatjaf
341f2bcb8d bump version to 1.2.4 2023-02-08 14:16:20 -03:00
fiatjaf
b2d1dd2110 a better way to do pubs and subs with SimplePool. 2023-02-08 14:15:54 -03:00
fiatjaf
75d7be5a54 use per-subscription alreadyHaveEvent handler instead of per-relay.
now pools are much smarter.
2023-02-08 14:15:54 -03:00
fiatjaf
b5c8255b2f fakejson match subscription id. 2023-02-08 14:15:54 -03:00
fiatjaf
4485c8ed5e remove broken globalThis error type. 2023-02-08 14:15:54 -03:00
fiatjaf
3710866430 replace package.json scripts with just. 2023-02-08 14:15:54 -03:00
fiatjaf
da59e3ce90 when in pool, automatically and efficiently deduplicate. 2023-02-08 09:46:05 -03:00
fiatjaf
cc8e34163d most simple relay pool. 2023-02-08 08:39:59 -03:00
gaodeng
9082953ede fix error event 2023-02-07 06:03:41 -03:00
Luis Miguel
61f397463d nip05 supports uppercase
nip05 says `NIP-05 assumes the <local-part> part will be restricted to the characters a-z0-9-_., case insensitive`

So a lot of people is starting the names with uppercase. See here:

`https://nostr-check.com/.well-known/nostr.json`

So I think we should change the regex to accept lowercase or uppercase.

Another way to do it would be to do a `.toLowerCase` at the beginning, but then we would need to do this search ignoring the case:

```
if (!res?.names?.[name])
```

So maybe for now this is enough?
2023-01-31 10:22:11 -03:00
fiatjaf
312b6fd035 add fast insert-into-sorted-list utils. 2023-01-28 18:07:14 -03:00
fiatjaf
7f1bd4f4a8 tag v1.2.0 2023-01-22 10:34:04 -03:00
fiatjaf
26089ef958 refactor previous commit a little, add fakejson module for simple parsing that doesn't use regex. 2023-01-22 10:32:33 -03:00
Martti Malmi
2e305b7cd4 incoming message queue, alreadyHaveEvent check, msg.send catch 2023-01-21 07:03:12 -03:00
fiatjaf
51c1a54ddf test every pull request. 2023-01-20 17:00:48 -03:00
fiatjaf
cb05ee188f increase bech32 max size to 5000. 2023-01-18 17:31:37 -03:00
jaonoctus
fa9e169c46 test(nip06): add nip06 2023-01-17 08:15:55 -03:00
jaonoctus
bb1e3f2fa6 feat(nip06): add passphrase optional param 2023-01-17 08:15:55 -03:00
David Strayhorn
160987472f Update README.md
remove extra &&
2023-01-14 08:20:10 -03:00
Callum Macdonald
8b18341ebb Minor typo fix 2023-01-09 15:00:28 -03:00
fiatjaf
901445dea1 tag v1.1.1 2023-01-04 10:16:15 -03:00
fiatjaf
91b67cd0d5 fix readme signing example.
fixes https://github.com/nbd-wtf/nostr-tools/issues/78
2023-01-04 10:15:16 -03:00
bayernator
1e696e0f3b increase nprofile, encodeBytes, nprofileEncode string length parameter to 1500 2023-01-03 16:20:42 -03:00
fiatjaf
4b36848b2d fix signing functions to be more strict and correct. 2022-12-29 18:26:28 -03:00
pseudozach
3cb351a5f4 fix typo 2022-12-28 09:18:09 -03:00
François-Xavier Thoorens
5db1934fa4 fixed security issue around event verification
the use of id has been removed and the hash is computed instead
2022-12-27 16:46:36 -03:00
fiatjaf
50c3f24b25 replace two packages with a @scure dependency that already existed. 2022-12-27 11:35:21 -03:00
fiatjaf
39ea47660d use a different relay for tests. 2022-12-25 16:01:31 -03:00
Tristan
8071e2f4fa Make opts arg optional for sub method
In the README and the code, it looks like the second argument for the relay's `sub` method is optional:

```typescript
let sub = relay.sub([
  {
    ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027']
  }
])
```

In the type definitions it's required however, which leads to an error in editors. Let's mark it as optional in the type definitions too! 👍
2022-12-25 14:32:20 -03:00
Tristan
cc2250da1f Add missing "error" event to on and off type definitions 2022-12-25 14:31:38 -03:00
rkfg
c37d10bb9d Fix resolveClose 2022-12-24 20:41:49 -03:00
rkfg
97e28fdf9a Fix connect/close return types and race condition 2022-12-24 18:49:16 -03:00
fiatjaf
87c0f0d061 tag v1.0.0 2022-12-23 20:51:36 -03:00
fiatjaf
83c397b839 do event signature and verification synchronously. 2022-12-23 17:32:13 -03:00
fiatjaf
cd7d1cec48 implement nip26 delegation. 2022-12-23 17:30:35 -03:00
adamritter
613a843838 Add Kind enum for easier client development (#61) 2022-12-23 16:38:59 -03:00
fiatjaf
74a0d5454a guard against some nonexisting arrays of event listeners. 2022-12-23 15:18:23 -03:00
fiatjaf
c0d1e41424 always recompute the hash when signing.
fixes https://github.com/fiatjaf/nostr-tools/issues/59
2022-12-23 15:06:21 -03:00
fiatjaf
f7e510e1c8 nip05 regex name check. 2022-12-23 15:04:24 -03:00
fiatjaf
c08bdac7a7 catch usage of global fetch for nodejs.
fixes https://github.com/fiatjaf/nostr-tools/issues/53
2022-12-23 11:36:37 -03:00
rkfg
c5b64404f6 Add limit to filter 2022-12-23 11:29:38 -03:00
adamritter
c7b26fdba2 Don't expose external API to hex representation of mnemoic 2022-12-23 11:01:10 -03:00
fiatjaf
ac698ef67d make relay.connect() an awaitable thing. 2022-12-22 08:53:40 -03:00
fiatjaf
8262a81cb2 make crypto available as a global on nip04 test. 2022-12-21 17:12:50 -03:00
fiatjaf
26e6da6ba3 we need websocket polyfill on relay tests. 2022-12-21 17:09:00 -03:00
fiatjaf
8aa31bb437 remove websocket-polyfill, instruct nodejs users to install it manually. 2022-12-21 16:23:47 -03:00
fiatjaf
4bd4469357 remove useless readable-stream dependency. 2022-12-21 16:19:59 -03:00
fiatjaf
89ae21f796 remove buffer usage everywhere. 2022-12-21 16:04:09 -03:00
fiatjaf
41a1614d89 remove browserify-cipher, use crypto.subtle for nip04. 2022-12-21 16:04:00 -03:00
fiatjaf
0500415a4e remove all the auto-reconnection code from relay. 2022-12-21 15:31:57 -03:00
fiatjaf
cee4357cab Merge pull request #50 from mmalmi/patch-1 2022-12-21 08:50:29 -03:00
Sandwich
d5cf5930d1 Fix example code in readme, resolves #47 2022-12-21 08:44:52 -03:00
Martti Malmi
a78e2036aa status code 3 (closed) for un-opened connection 2022-12-21 11:15:36 +02:00
Martti Malmi
adc1854ac6 relay.status() returns 0 when ws not created 2022-12-21 11:08:10 +02:00
fiatjaf
83148e8bdf fix small things in README. 2022-12-20 22:34:19 -03:00
fiatjaf
364c37cac5 fix autopublishing to npm. 2022-12-20 20:15:43 -03:00
fiatjaf
385cdb4ac6 README examples for nip05 and nip19. 2022-12-20 18:42:24 -03:00
fiatjaf
3f1025f551 nip05.queryProfile() and test. 2022-12-20 18:36:49 -03:00
fiatjaf
482c5affd4 add nip19. 2022-12-20 18:26:30 -03:00
fiatjaf
679ac0c133 fix standalone script URL. 2022-12-20 17:01:35 -03:00
fiatjaf
b96159ad36 better publishing built files. 2022-12-20 16:56:05 -03:00
fiatjaf
6dede4a688 use semisol relay that has our desired event on test. 2022-12-20 16:26:55 -03:00
fiatjaf
50c8bb72f9 v1.0.0-alpha 2022-12-20 16:16:59 -03:00
fiatjaf
72781e0eab nip05 typescript fixes. 2022-12-20 16:16:59 -03:00
fiatjaf
bf120c1348 relay examples on README. 2022-12-20 16:16:59 -03:00
fiatjaf
3630d377e5 test every commit on github actions. 2022-12-20 16:16:59 -03:00
fiatjaf
53b0091bf4 some fixes on relay.ts and tests. 2022-12-20 15:25:34 -03:00
fiatjaf
1a7cc5f21f updated readme with nicer examples. 2022-12-19 20:13:08 -03:00
fiatjaf
1162935f58 add a bunch of tests. 2022-12-19 20:02:01 -03:00
fiatjaf
a49d971f6a reorganize index.ts to use "export *". 2022-12-19 19:51:38 -03:00
fiatjaf
897919be3b get rid of create-hash, use noble hashes. 2022-12-19 19:50:41 -03:00
fiatjaf
39aca167fb build for commonjs, esm and a standalone bundle. 2022-12-19 15:46:31 -03:00
fiatjaf
de8bdd8370 fix typescript types everywhere, delete pool.js and refactor relay.js to use event listeners everywhere. 2022-12-18 17:02:19 -03:00
Íñigo Aréjula Aísa
46a0a342db event fields (#37) 2022-12-17 13:09:25 -03:00
Leo Wandersleb
4fe2a9c91a use default fetch if in service worker (#23) 2022-12-17 13:08:59 -03:00
fiatjaf
e62b833464 Merge pull request #41 from monlovesmango/cb-api
refactor of cb api
2022-12-17 13:06:30 -03:00
monica
100c77d2aa finalize cb api 2022-12-07 22:26:51 -06:00
Íñigo Aréjula Aísa
12be5a5338 Fix tag type
I realized that tags were an array of array, if it is correct merge, if im wrong just discard
2022-12-05 16:40:47 -03:00
monica
b955ba2a09 initial refactor of cb api 2022-12-04 21:57:15 -06:00
Íñigo Aréjula Aísa
ec805be4ab expose nip 4 functions to TS (#39) 2022-11-30 19:41:10 -03:00
Íñigo Aréjula Aísa
92fb339afb Update relay.js 2022-11-27 13:57:21 +01:00
Íñigo Aréjula Aísa
f8f125270a fix return value getRelayList 2022-11-26 11:16:20 -03:00
Íñigo Aréjula Aísa
1b798b2eee Expose relay funcs (#31) 2022-11-25 23:21:33 -03:00
Íñigo Aréjula Aísa
ae717a1a4a Documentation pool.js (#30) 2022-11-25 23:20:22 -03:00
Íñigo Aréjula Aísa
b2015c8fe5 Filter type with optional atributtes 2022-11-25 13:03:45 -03:00
fiatjaf
c5d2e3b037 github action to publish to npm on tag. 2022-11-21 20:26:58 -03:00
fiatjaf
0ef5d1e19c Merge pull request #27 from monlovesmango/Expose-EOSE-Relay-URL 2022-11-21 20:24:01 -03:00
monlovesmango
7d9d10fdb1 add relay url arg to eoseCb 2022-11-21 16:11:30 -06:00
monlovesmango
a1e1ce131a include eoseCb in sub 2022-11-21 16:09:23 -06:00
Fred
cdb07bb175 replace micro-bip with @scure/bip, fix #25 2022-10-24 06:16:11 -03:00
Leo Wandersleb
1f1bcff803 EOSE nip-15 2022-09-29 07:23:48 -03:00
fiatjaf
896af30619 release v0.24.1 2022-09-06 15:19:29 -03:00
bjong
a8542c4b56 fix: CJK characters are garbled after decryption 2022-09-06 15:17:50 -03:00
fiatjaf
9f9e822c6d allow skipping signature verification. 2022-08-05 16:36:27 -03:00
Lennon Day-Reynolds
821a8f7895 TypeScript definitions (#18) 2022-07-15 15:49:49 -03:00
70 changed files with 9821 additions and 757 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

@@ -1,5 +1,9 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "babel"],
"parserOptions": {
"ecmaVersion": 9,
"ecmaFeatures": {
@@ -14,14 +18,11 @@
"node": true
},
"plugins": [
"babel"
],
"globals": {
"document": false,
"navigator": false,
"window": false,
"crypto": false,
"location": false,
"URL": false,
"URLSearchParams": false,
@@ -33,23 +34,23 @@
"rules": {
"accessor-pairs": 2,
"arrow-spacing": [2, { "before": true, "after": true }],
"arrow-spacing": [2, {"before": true, "after": true}],
"block-spacing": [2, "always"],
"brace-style": [2, "1tbs", { "allowSingleLine": true }],
"brace-style": [2, "1tbs", {"allowSingleLine": true}],
"comma-dangle": 0,
"comma-spacing": [2, { "before": false, "after": true }],
"comma-spacing": [2, {"before": false, "after": true}],
"comma-style": [2, "last"],
"constructor-super": 2,
"curly": [0, "multi-line"],
"dot-location": [2, "property"],
"eol-last": 2,
"eqeqeq": [2, "allow-null"],
"generator-star-spacing": [2, { "before": true, "after": true }],
"handle-callback-err": [2, "^(err|error)$" ],
"generator-star-spacing": [2, {"before": true, "after": true}],
"handle-callback-err": [2, "^(err|error)$"],
"indent": 0,
"jsx-quotes": [2, "prefer-double"],
"key-spacing": [2, { "beforeColon": false, "afterColon": true }],
"keyword-spacing": [2, { "before": true, "after": true }],
"key-spacing": [2, {"beforeColon": false, "afterColon": true}],
"keyword-spacing": [2, {"before": true, "after": true}],
"new-cap": 0,
"new-parens": 0,
"no-array-constructor": 2,
@@ -81,12 +82,12 @@
"no-irregular-whitespace": 2,
"no-iterator": 2,
"no-label-var": 2,
"no-labels": [2, { "allowLoop": false, "allowSwitch": false }],
"no-labels": [2, {"allowLoop": false, "allowSwitch": false}],
"no-lone-blocks": 2,
"no-mixed-spaces-and-tabs": 2,
"no-multi-spaces": 2,
"no-multi-str": 2,
"no-multiple-empty-lines": [2, { "max": 2 }],
"no-multiple-empty-lines": [2, {"max": 2}],
"no-native-reassign": 2,
"no-negated-in-lhs": 2,
"no-new": 0,
@@ -100,7 +101,6 @@
"no-octal-escape": 2,
"no-path-concat": 0,
"no-proto": 2,
"no-redeclare": 2,
"no-regex-spaces": 2,
"no-return-assign": 0,
"no-self-assign": 2,
@@ -115,23 +115,34 @@
"no-undef": 2,
"no-undef-init": 2,
"no-unexpected-multiline": 2,
"no-unneeded-ternary": [2, { "defaultAssignment": false }],
"no-unneeded-ternary": [2, {"defaultAssignment": false}],
"no-unreachable": 2,
"no-unused-vars": [2, { "vars": "local", "args": "none", "varsIgnorePattern": "^_"}],
"no-unused-vars": [
2,
{"vars": "local", "args": "none", "varsIgnorePattern": "^_"}
],
"no-useless-call": 2,
"no-useless-constructor": 2,
"no-with": 2,
"one-var": [0, { "initialized": "never" }],
"operator-linebreak": [2, "after", { "overrides": { "?": "before", ":": "before" } }],
"one-var": [0, {"initialized": "never"}],
"operator-linebreak": [
2,
"after",
{"overrides": {"?": "before", ":": "before"}}
],
"padded-blocks": [2, "never"],
"quotes": [2, "single", { "avoidEscape": true, "allowTemplateLiterals": true }],
"quotes": [
2,
"single",
{"avoidEscape": true, "allowTemplateLiterals": true}
],
"semi": [2, "never"],
"semi-spacing": [2, { "before": false, "after": true }],
"semi-spacing": [2, {"before": false, "after": true}],
"space-before-blocks": [2, "always"],
"space-before-function-paren": 0,
"space-in-parens": [2, "never"],
"space-infix-ops": 2,
"space-unary-ops": [2, { "words": true, "nonwords": false }],
"space-unary-ops": [2, {"words": true, "nonwords": false}],
"spaced-comment": 0,
"template-curly-spacing": [2, "never"],
"use-isnan": 2,
@@ -139,5 +150,13 @@
"wrap-iife": [2, "any"],
"yield-star-spacing": [2, "both"],
"yoda": [0]
}
},
"overrides": [
{
"files": ["**/*.test.ts"],
"env": { "jest/globals": true },
"plugins": ["jest"],
"extends": ["plugin:jest/recommended"]
}
]
}

23
.github/workflows/npm-publish.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: publish npm package
on:
push:
tags: [v*]
jobs:
publish-npm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- uses: extractions/setup-just@v1
- 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 }}
greater-version-only: true

18
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,18 @@
name: test every commit
on:
push:
branches:
- master
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- uses: extractions/setup-just@v1
- run: just install-dependencies
- run: just test

4
.gitignore vendored
View File

@@ -2,4 +2,6 @@ node_modules
dist
yarn.lock
package-lock.json
nostr.js
.envrc
lib
test.html

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>

379
README.md
View File

@@ -2,88 +2,329 @@
Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
## Usage
Only depends on _@scure_ and _@noble_ packages.
```js
import {relayPool} from 'nostr-tools'
This package is only providing lower-level functionality. If you want an easy-to-use fully-fledged solution that abstracts the hard parts of Nostr and makes decisions on your behalf, take a look at [NDK](https://github.com/nostr-dev-kit/ndk) and [@snort/system](https://www.npmjs.com/package/@snort/system).
const pool = relayPool()
## Installation
pool.setPrivateKey('<hex>') // optional
pool.addRelay('ws://some.relay.com', {read: true, write: true})
pool.addRelay('ws://other.relay.cool', {read: true, write: true})
// example callback function for a subscription
function onEvent(event, relay) {
console.log(`got an event from ${relay.url} which is already validated.`, event)
}
// subscribing to a single user
// author is the user's public key
pool.sub({cb: onEvent, filter: {author: '<hex>'}})
// or bulk follow
pool.sub({cb:(event, relay) => {...}, filter: {authors: ['<hex1>', '<hex2>', ..., '<hexn>']}})
// reuse a subscription channel
const mySubscription = pool.sub({cb: ..., filter: ....})
mySubscription.sub({filter: ....})
mySubscription.sub({cb: ...})
mySubscription.unsub()
// get specific event
const specificChannel = pool.sub({
cb: (event, relay) => {
console.log('got specific event from relay', event, relay)
specificChannel.unsub()
},
filter: {id: '<hex>'}
})
// or get a specific event plus all the events that reference it in the 'e' tag
pool.sub({ cb: (event, relay) => { ... }, filter: [{id: '<hex>'}, {'#e': '<hex>'}] })
// get all events
pool.sub({cb: (event, relay) => {...}, filter: {}})
// get recent events
pool.sub({cb: (event, relay) => {...}, filter: {since: timestamp}})
// publishing events(inside an async function):
const ev = await pool.publish(eventObject, (status, url) => {
if (status === 0) {
console.log(`publish request sent to ${url}`)
}
if (status === 1) {
console.log(`event published by ${url}`, ev)
}
})
// it will be signed automatically with the key supplied above
// or pass an already signed event to bypass this
// subscribing to a new relay
pool.addRelay('<url>')
// will automatically subscribe to the all the events called with .sub above
```bash
npm install nostr-tools # or yarn add nostr-tools
```
All functions expect bytearrays as hex strings and output bytearrays as hex strings.
## Usage
For other utils please read the source (for now).
### Generating a private key and a public key
```js
import {generatePrivateKey, getPublicKey} from 'nostr-tools'
let sk = generatePrivateKey() // `sk` is a hex string
let pk = getPublicKey(sk) // `pk` is a hex string
```
### Creating, signing and verifying events
```js
import {
validateEvent,
verifySignature,
getSignature,
getEventHash,
getPublicKey
} from 'nostr-tools'
let event = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'hello',
pubkey: getPublicKey(privateKey)
}
event.id = getEventHash(event)
event.sig = getSignature(event, privateKey)
let ok = validateEvent(event)
let veryOk = verifySignature(event)
```
### Interacting with a relay
```js
import {
relayInit,
generatePrivateKey,
getPublicKey,
getEventHash,
getSignature
} from 'nostr-tools'
const relay = relayInit('wss://relay.example.com')
relay.on('connect', () => {
console.log(`connected to ${relay.url}`)
})
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([
{
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027']
}
])
sub.on('event', event => {
console.log('we got the event we wanted:', event)
})
sub.on('eose', () => {
sub.unsub()
})
// let's publish a new event while simultaneously monitoring the relay for it
let sk = generatePrivateKey()
let pk = getPublicKey(sk)
let sub = relay.sub([
{
kinds: [1],
authors: [pk]
}
])
sub.on('event', event => {
console.log('got event:', event)
})
let event = {
kind: 1,
pubkey: pk,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'hello world'
}
event.id = getEventHash(event)
event.sig = getSignature(event, sk)
let pub = relay.publish(event)
pub.on('ok', () => {
console.log(`${relay.url} has accepted our event`)
})
pub.on('failed', reason => {
console.log(`failed to publish to ${relay.url}: ${reason}`)
})
let events = await relay.list([{kinds: [0, 1]}])
let event = await relay.get({
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245']
})
relay.close()
```
To use this on Node.js you first must install `websocket-polyfill` and import it:
```js
import 'websocket-polyfill'
```
### Interacting with multiple relays
```js
import {SimplePool} from 'nostr-tools'
const pool = new SimplePool()
let relays = ['wss://relay.example.com', 'wss://relay.example2.com']
let sub = pool.sub(
[...relays, 'wss://relay.example3.com'],
[
{
authors: [
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
]
}
]
)
sub.on('event', event => {
// this will only be called once the first time the event is received
// ...
})
let pubs = pool.publish(relays, newEvent)
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
```js
import {nip05} from 'nostr-tools'
let profile = await nip05.queryProfile('jb55.com')
console.log(profile.pubkey)
// prints: 32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245
console.log(profile.relays)
// prints: [wss://relay.damus.io]
```
To use this on Node.js you first must install `node-fetch@2` and call something like this:
```js
nip05.useFetchImplementation(require('node-fetch'))
```
### Encoding and decoding NIP-19 codes
```js
import {nip19, generatePrivateKey, getPublicKey} from 'nostr-tools'
let sk = generatePrivateKey()
let nsec = nip19.nsecEncode(sk)
let {type, data} = nip19.decode(nsec)
assert(type === 'nsec')
assert(data === sk)
let pk = getPublicKey(generatePrivateKey())
let npub = nip19.npubEncode(pk)
let {type, data} = nip19.decode(npub)
assert(type === 'npub')
assert(data === pk)
let pk = getPublicKey(generatePrivateKey())
let relays = [
'wss://relay.nostr.example.mydomain.example.com',
'wss://nostr.banana.com'
]
let nprofile = nip19.nprofileEncode({pubkey: pk, relays})
let {type, data} = nip19.decode(nprofile)
assert(type === 'nprofile')
assert(data.pubkey === pk)
assert(data.relays.length === 2)
```
### Encrypting and decrypting direct messages
```js
import {nip04, getPublicKey, generatePrivateKey} from 'nostr-tools'
// sender
let sk1 = generatePrivateKey()
let pk1 = getPublicKey(sk1)
// receiver
let sk2 = generatePrivateKey()
let pk2 = getPublicKey(sk2)
// on the sender side
let message = 'hello'
let ciphertext = await nip04.encrypt(sk1, pk2, message)
let event = {
kind: 4,
pubkey: pk1,
tags: [['p', pk2]],
content: ciphertext,
...otherProperties
}
sendEvent(event)
// on the receiver side
sub.on('event', event => {
let sender = event.pubkey
pk1 === sender
let plaintext = await nip04.decrypt(sk2, pk1, event.content)
})
```
### Performing and checking for delegation
```js
import {nip26, getPublicKey, generatePrivateKey} from 'nostr-tools'
// delegator
let sk1 = generatePrivateKey()
let pk1 = getPublicKey(sk1)
// delegatee
let sk2 = generatePrivateKey()
let pk2 = getPublicKey(sk2)
// generate delegation
let delegation = nip26.createDelegation(sk1, {
pubkey: pk2,
kind: 1,
since: Math.round(Date.now() / 1000),
until: Math.round(Date.now() / 1000) + 60 * 60 * 24 * 30 /* 30 days */
})
// the delegatee uses the delegation when building an event
let event = {
pubkey: pk2,
kind: 1,
created_at: Math.round(Date.now() / 1000),
content: 'hello from a delegated key',
tags: [['delegation', delegation.from, delegation.cond, delegation.sig]]
}
// finally any receiver of this event can check for the presence of a valid delegation tag
let delegator = nip26.getDelegator(event)
assert(delegator === pk1) // will be null if there is no delegation tag or if it is invalid
```
Please consult the tests or [the source code](https://github.com/fiatjaf/nostr-tools) for more information that isn't available here.
### Using from the browser (if you don't want to use a bundler)
You can import nostr-tools as an ES module. Just add a script tag like this:
```html
<script type="module">
import {generatePrivateKey} from 'https://unpkg.com/nostr-tools/nostr.js'
console.log(generatePrivateKey())
<script src="https://unpkg.com/nostr-tools/lib/nostr.bundle.js"></script>
<script>
window.NostrTools.generatePrivateKey('...') // and so on
</script>
```
And import whatever function you would import from `"nostr-tools"` in a bundler.
## 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,25 +1,47 @@
#!/usr/bin/env node
const fs = require('fs')
const esbuild = require('esbuild')
const alias = require('esbuild-plugin-alias')
const nodeGlobals = require('@esbuild-plugins/node-globals-polyfill').default
const buildOptions = {
entryPoints: ['index.js'],
outfile: 'nostr.js',
let common = {
entryPoints: ['index.ts'],
bundle: true,
format: 'esm',
plugins: [
alias({
stream: require.resolve('readable-stream')
}),
nodeGlobals({buffer: true})
],
define: {
window: 'self',
global: 'self'
},
loader: {'.js': 'jsx'}
sourcemap: 'external'
}
esbuild.build(buildOptions).then(() => console.log('build success.'))
esbuild
.build({
...common,
outfile: 'lib/esm/nostr.mjs',
format: 'esm',
packages: 'external'
})
.then(() => {
const packageJson = JSON.stringify({type: 'module'})
fs.writeFileSync(`${__dirname}/lib/esm/package.json`, packageJson, 'utf8')
console.log('esm build success.')
})
esbuild
.build({
...common,
outfile: 'lib/nostr.cjs.js',
format: 'cjs',
packages: 'external'
})
.then(() => console.log('cjs build success.'))
esbuild
.build({
...common,
outfile: 'lib/nostr.bundle.js',
format: 'iife',
globalName: 'NostrTools',
define: {
window: 'self',
global: 'self',
process: '{"env": {}}'
}
})
.then(() => console.log('standalone build success.'))

View File

@@ -1,58 +0,0 @@
import {Buffer} from 'buffer'
import createHash from 'create-hash'
import * as secp256k1 from '@noble/secp256k1'
export function getBlankEvent() {
return {
kind: 255,
pubkey: null,
content: '',
tags: [],
created_at: 0
}
}
export function serializeEvent(evt) {
return JSON.stringify([
0,
evt.pubkey,
evt.created_at,
evt.kind,
evt.tags,
evt.content
])
}
export function getEventHash(event) {
let eventHash = createHash('sha256')
.update(Buffer.from(serializeEvent(event)))
.digest()
return Buffer.from(eventHash).toString('hex')
}
export function validateEvent(event) {
if (event.id !== getEventHash(event)) return false
if (typeof event.content !== 'string') return false
if (typeof event.created_at !== 'number') return false
if (!Array.isArray(event.tags)) return false
for (let i = 0; i < event.tags.length; i++) {
let tag = event.tags[i]
if (!Array.isArray(tag)) return false
for (let j = 0; j < tag.length; j++) {
if (typeof tag[j] === 'object') return false
}
}
return true
}
export function verifySignature(event) {
return secp256k1.schnorr.verify(event.sig, event.id, event.pubkey)
}
export async function signEvent(event, key) {
return Buffer.from(
await secp256k1.schnorr.sign(getEventHash(event), key)
).toString('hex')
}

352
event.test.ts Normal file
View File

@@ -0,0 +1,352 @@
import {
getBlankEvent,
finishEvent,
serializeEvent,
getEventHash,
validateEvent,
verifySignature,
getSignature,
Kind,
} from './event.ts'
import {getPublicKey} from './keys.ts'
describe('Event', () => {
describe('getBlankEvent', () => {
it('should return a blank event object', () => {
expect(getBlankEvent()).toEqual({
kind: 255,
content: '',
tags: [],
created_at: 0
})
})
it('should return a blank event object with defined kind', () => {
expect(getBlankEvent(Kind.Text)).toEqual({
kind: 1,
content: '',
tags: [],
created_at: 0
})
})
})
describe('finishEvent', () => {
it('should create a signed event from a template', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const template = {
kind: Kind.Text,
tags: [],
content: 'Hello, world!',
created_at: 1617932115
}
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(() => {
// @ts-expect-error
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('getSignature', () => {
it('should produce the correct signature for 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 = getSignature(unsignedEvent, privateKey)
// verify the signature
// @ts-expect-error
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 = getSignature(unsignedEvent, wrongPrivateKey)
// verify the signature
// @ts-expect-error
const isValid = verifySignature({
...unsignedEvent,
sig
})
expect(typeof sig).toEqual('string')
expect(sig.length).toEqual(128)
expect(isValid).toEqual(false)
})
})
})

138
event.ts Normal file
View File

@@ -0,0 +1,138 @@
import {schnorr} from '@noble/curves/secp256k1'
import {sha256} from '@noble/hashes/sha256'
import {bytesToHex} from '@noble/hashes/utils'
import {getPublicKey} from './keys.ts'
import {utf8Encoder} from './utils.ts'
/* eslint-disable no-unused-vars */
export enum Kind {
Metadata = 0,
Text = 1,
RecommendRelay = 2,
Contacts = 3,
EncryptedDirectMessage = 4,
EventDeletion = 5,
Repost = 6,
Reaction = 7,
BadgeAward = 8,
ChannelCreation = 40,
ChannelMetadata = 41,
ChannelMessage = 42,
ChannelHideMessage = 43,
ChannelMuteUser = 44,
Blank = 255,
Report = 1984,
ZapRequest = 9734,
Zap = 9735,
RelayList = 10002,
ClientAuth = 22242,
HttpAuth = 27235,
ProfileBadge = 30008,
BadgeDefinition = 30009,
Article = 30023
}
export type EventTemplate<K extends number = Kind> = {
kind: K
tags: string[][]
content: string
created_at: number
}
export type UnsignedEvent<K extends number = Kind> = EventTemplate<K> & {
pubkey: string
}
export type Event<K extends number = Kind> = UnsignedEvent<K> & {
id: string
sig: string
}
export function getBlankEvent(): EventTemplate<Kind.Blank>
export function getBlankEvent<K extends number>(kind: K): EventTemplate<K>
export function getBlankEvent<K>(kind: K | Kind.Blank = Kind.Blank) {
return {
kind,
content: '',
tags: [],
created_at: 0
}
}
export function finishEvent<K extends number = Kind>(
t: EventTemplate<K>,
privateKey: string
): Event<K> {
let event = t as Event<K>
event.pubkey = getPublicKey(privateKey)
event.id = getEventHash(event)
event.sig = getSignature(event, privateKey)
return event
}
export function serializeEvent(evt: UnsignedEvent<number>): string {
if (!validateEvent(evt))
throw new Error("can't serialize event with wrong or missing properties")
return JSON.stringify([
0,
evt.pubkey,
evt.created_at,
evt.kind,
evt.tags,
evt.content
])
}
export function getEventHash(event: UnsignedEvent<number>): string {
let eventHash = sha256(utf8Encoder.encode(serializeEvent(event)))
return bytesToHex(eventHash)
}
const isRecord = (obj: unknown): obj is Record<string, unknown> =>
obj instanceof Object
export function validateEvent<T>(event: T): event is T & UnsignedEvent<number> {
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
if (!event.pubkey.match(/^[a-f0-9]{64}$/)) return false
if (!Array.isArray(event.tags)) return false
for (let i = 0; i < event.tags.length; i++) {
let tag = event.tags[i]
if (!Array.isArray(tag)) return false
for (let j = 0; j < tag.length; j++) {
if (typeof tag[j] === 'object') return false
}
}
return true
}
export function verifySignature(event: Event<number>): boolean {
try {
return schnorr.verify(event.sig, getEventHash(event), event.pubkey)
} catch (err) {
return false
}
}
/** @deprecated Use `getSignature` instead. */
export function signEvent(event: UnsignedEvent<number>, key: string): string {
console.warn(
'nostr-tools: `signEvent` is deprecated and will be removed or changed in the future. Please use `getSignature` instead.'
)
return getSignature(event, key)
}
/** Calculate the signature for an event. */
export function getSignature(
event: UnsignedEvent<number>,
key: string
): string {
return bytesToHex(schnorr.sign(getEventHash(event), key))
}

47
fakejson.test.ts Normal file
View File

@@ -0,0 +1,47 @@
import {matchEventId, matchEventKind, getSubscriptionId} from './fakejson.ts'
test('match id', () => {
expect(
matchEventId(
`["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`,
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146'
)
).toBeTruthy()
expect(
matchEventId(
`["EVENT","nostril-query",{"content":"a bunch of mfs interacted with my post using what I assume were \"likes\": https://nostr.build/i/964.png","created_at":1672506879,"id":"f40bdd0905137ad60482537e260890ab50b0863bf16e67cf9383f203bd26c96f","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"8b825d2d4096f0643b18ca39da59ec07a682cd8a3e717f119c845037573d98099f5bea94ec7ddedd5600c8020144a255ed52882a911f7f7ada6d6abb3c0a1eb4","tags":[]}]`,
'fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146'
)
).toBeFalsy()
})
test('match kind', () => {
expect(
matchEventKind(
`["EVENT","nostril-query",{"tags":[],"content":"so did we cut all corners and p2p stuff in order to make a decentralized social network that was fast and worked, but in the end what we got was a lot of very slow clients that can't handle the traffic of one jack dorsey tweet?","sig":"ca62629d189edebb8f0811cfa0ac53015013df5f305dcba3f411ba15cfc4074d8c2d517ee7d9e81c9eb72a7328bfbe31c9122156397565ac55e740404e2b1fe7","id":"fef2a50f7d9d3d5a5f38ee761bc087ec16198d3f0140df6d1e8193abf7c2b146","kind":1,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1671150419}]`,
1
)
).toBeTruthy()
expect(
matchEventKind(
`["EVENT","nostril-query",{"content":"{\"name\":\"fiatjaf\",\"about\":\"buy my merch at fiatjaf store\",\"picture\":\"https://fiatjaf.com/static/favicon.jpg\",\"nip05\":\"_@fiatjaf.com\"}","created_at":1671217411,"id":"b52f93f6dfecf9d81f59062827cd941412a0e8398dda60baf960b17499b88900","kind":12720,"pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","sig":"fc1ea5d45fa5ed0526faed06e8fc7a558e60d1b213e9714f440828584ee999b93407092f9b04deea7e504fa034fc0428f31f7f0f95417b3280ebe6004b80b470","tags":[]}]`,
12720
)
).toBeTruthy()
})
test('match subscription id', () => {
expect(getSubscriptionId('["EVENT","",{}]')).toEqual('')
expect(getSubscriptionId('["EVENT","_",{}]')).toEqual('_')
expect(getSubscriptionId('["EVENT","subname",{}]')).toEqual('subname')
expect(getSubscriptionId('["EVENT", "kasjbdjkav", {}]')).toEqual(
'kasjbdjkav'
)
expect(
getSubscriptionId(
' [ \n\n "EVENT" , \n\n "y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH" , {}]'
)
).toEqual('y4d5ow45gfwoiudfÇA VSADLKAN KLDASB[12312535]SFMZSNJKLH')
})

41
fakejson.ts Normal file
View File

@@ -0,0 +1,41 @@
export function getHex64(json: string, field: string): string {
let len = field.length + 3
let idx = json.indexOf(`"${field}":`) + len
let s = json.slice(idx).indexOf(`"`) + idx + 1
return json.slice(s, s + 64)
}
export function getInt(json: string, field: string): number {
let len = field.length
let idx = json.indexOf(`"${field}":`) + len + 3
let sliced = json.slice(idx)
let end = Math.min(sliced.indexOf(','), sliced.indexOf('}'))
return parseInt(sliced.slice(0, end), 10)
}
export function getSubscriptionId(json: string): string | null {
let idx = json.slice(0, 22).indexOf(`"EVENT"`)
if (idx === -1) return null
let pstart = json.slice(idx + 7 + 1).indexOf(`"`)
if (pstart === -1) return null
let start = idx + 7 + 1 + pstart
let pend = json.slice(start + 1, 80).indexOf(`"`)
if (pend === -1) return null
let end = start + 1 + pend
return json.slice(start + 1, end)
}
export function matchEventId(json: string, id: string): boolean {
return id === getHex64(json, 'id')
}
export function matchEventPubkey(json: string, pubkey: string): boolean {
return pubkey === getHex64(json, 'pubkey')
}
export function matchEventKind(json: string, kind: number): boolean {
return kind === getInt(json, 'kind')
}

View File

@@ -1,30 +0,0 @@
export function matchFilter(filter, event) {
if (filter.ids && filter.ids.indexOf(event.id) === -1) return false
if (filter.kinds && filter.kinds.indexOf(event.kind) === -1) return false
if (filter.authors && filter.authors.indexOf(event.pubkey) === -1)
return false
for (let f in filter) {
if (f[0] === '#') {
if (
filter[f] &&
!event.tags.find(
([t, v]) => t === f.slice(1) && filter[f].indexOf(v) !== -1
)
)
return false
}
}
if (filter.since && event.created_at < filter.since) return false
if (filter.until && event.created_at >= filter.until) return false
return true
}
export function matchFilters(filters, event) {
for (let i = 0; i < filters.length; i++) {
if (matchFilter(filters[i], event)) return true
}
return false
}

248
filter.test.ts Normal file
View File

@@ -0,0 +1,248 @@
import {matchFilter, matchFilters, mergeFilters} from './filter.ts'
import {buildEvent} from './test-helpers.ts'
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 = buildEvent({
id: '123',
kind: 1,
pubkey: 'abc',
created_at: 150,
tags: [['tag', 'value']]
})
const result = matchFilter(filter, event)
expect(result).toEqual(true)
})
it('should return false when the event id is not in the filter', () => {
const filter = {ids: ['123', '456']}
const event = buildEvent({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 = buildEvent({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 = buildEvent({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 = buildEvent({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 = buildEvent({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 = buildEvent({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 = buildEvent({
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 = buildEvent({created_at: 50})
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})
it('should return true when the timestamp of event is equal to the filter since value', () => {
const filter = {since: 100}
const event = buildEvent({created_at: 100})
const result = matchFilter(filter, event)
expect(result).toEqual(true)
})
it('should return false when the event is after the filter until value', () => {
const filter = {until: 100}
const event = buildEvent({created_at: 150})
const result = matchFilter(filter, event)
expect(result).toEqual(false)
})
it('should return true when the timestamp of event is equal to the filter until value', () => {
const filter = {until: 100}
const event = buildEvent({created_at: 100})
const result = matchFilter(filter, event)
expect(result).toEqual(true)
})
})
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 = buildEvent({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 = buildEvent({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 = buildEvent({
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 = buildEvent({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 = buildEvent({
id: '456',
kind: 2,
pubkey: 'def',
created_at: 200
})
const result = matchFilters(filters, event)
expect(result).toEqual(false)
})
})
describe('mergeFilters', () => {
it('should merge filters', () => {
expect(
mergeFilters(
{ids: ['a', 'b'], limit: 3},
{authors: ['x'], ids: ['b', 'c']}
)
).toEqual({ids: ['a', 'b', 'c'], limit: 3, authors: ['x']})
expect(
mergeFilters(
{kinds: [1], since: 15, until: 30},
{since: 10, kinds: [7], until: 15},
{kinds: [9, 10]}
)
).toEqual({kinds: [1, 7, 9, 10], since: 10, until: 30})
})
})
})

92
filter.ts Normal file
View File

@@ -0,0 +1,92 @@
import {Event, type Kind} from './event.ts'
export type Filter<K extends number = Kind> = {
ids?: string[]
kinds?: K[]
authors?: string[]
since?: number
until?: number
limit?: number
search?: string
[key: `#${string}`]: string[]
}
export function matchFilter(
filter: Filter<number>,
event: Event<number>
): boolean {
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) {
if (!filter.authors.some(prefix => event.pubkey.startsWith(prefix))) {
return false
}
}
for (let f in filter) {
if (f[0] === '#') {
let tagName = f.slice(1)
let values = filter[`#${tagName}`]
if (
values &&
!event.tags.find(
([t, v]) => t === f.slice(1) && values.indexOf(v) !== -1
)
)
return false
}
}
if (filter.since && event.created_at < filter.since) return false
if (filter.until && event.created_at > filter.until) return false
return true
}
export function matchFilters(
filters: Filter<number>[],
event: Event<number>
): boolean {
for (let i = 0; i < filters.length; i++) {
if (matchFilter(filters[i], event)) return true
}
return false
}
export function mergeFilters(...filters: Filter<number>[]): Filter<number> {
let result: Filter<number> = {}
for (let i = 0; i < filters.length; i++) {
let filter = filters[i]
Object.entries(filter).forEach(([property, values]) => {
if (
property === 'kinds' ||
property === 'ids' ||
property === 'authors' ||
property[0] === '#'
) {
// @ts-ignore
result[property] = result[property] || []
// @ts-ignore
for (let v = 0; v < values.length; v++) {
// @ts-ignore
let value = values[v]
// @ts-ignore
if (!result[property].includes(value)) result[property].push(value)
}
}
})
if (filter.limit && (!result.limit || filter.limit > result.limit))
result.limit = filter.limit
if (filter.until && (!result.until || filter.until > result.until))
result.until = filter.until
if (filter.since && (!result.since || filter.since < result.since))
result.since = filter.since
}
return result
}

View File

@@ -1,27 +0,0 @@
import {generatePrivateKey, getPublicKey} from './keys'
import {relayConnect} from './relay'
import {relayPool} from './pool'
import {
getBlankEvent,
signEvent,
validateEvent,
verifySignature,
serializeEvent,
getEventHash
} from './event'
import {matchFilter, matchFilters} from './filter'
export {
generatePrivateKey,
relayConnect,
relayPool,
signEvent,
validateEvent,
verifySignature,
serializeEvent,
getEventHash,
getPublicKey,
getBlankEvent,
matchFilter,
matchFilters
}

25
index.ts Normal file
View File

@@ -0,0 +1,25 @@
export * from './keys.ts'
export * from './relay.ts'
export * from './event.ts'
export * from './filter.ts'
export * from './pool.ts'
export * from './references.ts'
export * as nip04 from './nip04.ts'
export * as nip05 from './nip05.ts'
export * as nip06 from './nip06.ts'
export * as nip10 from './nip10.ts'
export * as nip13 from './nip13.ts'
export * as nip18 from './nip18.ts'
export * as nip19 from './nip19.ts'
export * as nip21 from './nip21.ts'
export * as nip25 from './nip25.ts'
export * as nip26 from './nip26.ts'
export * as nip27 from './nip27.ts'
export * as nip39 from './nip39.ts'
export * as nip42 from './nip42.ts'
export * as nip57 from './nip57.ts'
export * as nip98 from './nip98.ts'
export * as fj from './fakejson.ts'
export * as utils from './utils.ts'

5
jest.config.js Normal file
View File

@@ -0,0 +1,5 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
}

23
justfile Normal file
View File

@@ -0,0 +1,23 @@
export PATH := "./node_modules/.bin:" + env_var('PATH')
install-dependencies:
yarn --ignore-engines
build:
rm -rf lib
node build.js
test:
jest
test-only file:
jest {{file}}
emit-types:
tsc # see tsconfig.json
publish: build emit-types
npm publish
format:
prettier --plugin-search-dir . --write .

10
keys.js
View File

@@ -1,10 +0,0 @@
import * as secp256k1 from '@noble/secp256k1'
import {Buffer} from 'buffer'
export function generatePrivateKey() {
return Buffer.from(secp256k1.utils.randomPrivateKey()).toString('hex')
}
export function getPublicKey(privateKey) {
return Buffer.from(secp256k1.schnorr.getPublicKey(privateKey)).toString('hex')
}

18
keys.test.ts Normal file
View File

@@ -0,0 +1,18 @@
import {generatePrivateKey, getPublicKey} from './keys.ts'
test('private key generation', () => {
expect(generatePrivateKey()).toMatch(/[a-f0-9]{64}/)
})
test('public key generation', () => {
expect(getPublicKey(generatePrivateKey())).toMatch(/[a-f0-9]{64}/)
})
test('public key from private key deterministic', () => {
let sk = generatePrivateKey()
let pk = getPublicKey(sk)
for (let i = 0; i < 5; i++) {
expect(getPublicKey(sk)).toEqual(pk)
}
})

10
keys.ts Normal file
View File

@@ -0,0 +1,10 @@
import {schnorr} from '@noble/curves/secp256k1'
import {bytesToHex} from '@noble/hashes/utils'
export function generatePrivateKey(): string {
return bytesToHex(schnorr.utils.randomPrivateKey())
}
export function getPublicKey(privateKey: string): string {
return bytesToHex(schnorr.getPublicKey(privateKey))
}

View File

@@ -1,42 +0,0 @@
import aes from 'browserify-cipher'
import {Buffer} from 'buffer'
import {randomBytes} from '@noble/hashes/utils'
import * as secp256k1 from '@noble/secp256k1'
export function encrypt(privkey, pubkey, text) {
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
const normalizedKey = getNormalizedX(key)
let iv = Uint8Array.from(randomBytes(16))
var cipher = aes.createCipheriv(
'aes-256-cbc',
Buffer.from(normalizedKey, 'hex'),
iv
)
let encryptedMessage = cipher.update(text, 'utf8', 'base64')
encryptedMessage += cipher.final('base64')
return `${encryptedMessage}?iv=${Buffer.from(iv.buffer).toString('base64')}`
}
export function decrypt(privkey, pubkey, ciphertext) {
let [cip, iv] = ciphertext.split('?iv=')
let key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
let normalizedKey = getNormalizedX(key)
var decipher = aes.createDecipheriv(
'aes-256-cbc',
Buffer.from(normalizedKey, 'hex'),
Buffer.from(iv, 'base64')
)
let decryptedMessage = decipher.update(cip, 'base64')
decryptedMessage += decipher.final('utf8')
return decryptedMessage
}
function getNormalizedX(key) {
return typeof key === 'string'
? key.substr(2, 64)
: Buffer.from(key.slice(1, 33)).toString('hex')
}

19
nip04.test.ts Normal file
View File

@@ -0,0 +1,19 @@
import crypto from 'node:crypto'
import {encrypt, decrypt} from './nip04.ts'
import {getPublicKey, generatePrivateKey} from './keys.ts'
// @ts-ignore
// eslint-disable-next-line no-undef
globalThis.crypto = crypto
test('encrypt and decrypt message', async () => {
let sk1 = generatePrivateKey()
let sk2 = generatePrivateKey()
let pk1 = getPublicKey(sk1)
let pk2 = getPublicKey(sk2)
expect(
await decrypt(sk2, pk1, await encrypt(sk1, pk2, 'hello'))
).toEqual('hello')
})

72
nip04.ts Normal file
View File

@@ -0,0 +1,72 @@
import {randomBytes} from '@noble/hashes/utils'
import {secp256k1} from '@noble/curves/secp256k1'
import {base64} from '@scure/base'
import {utf8Decoder, utf8Encoder} from './utils.ts'
// @ts-ignore
if (typeof crypto !== 'undefined' && !crypto.subtle && crypto.webcrypto) {
// @ts-ignore
crypto.subtle = crypto.webcrypto.subtle
}
export async function encrypt(
privkey: string,
pubkey: string,
text: string
): Promise<string> {
const key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
const normalizedKey = getNormalizedX(key)
let iv = Uint8Array.from(randomBytes(16))
let plaintext = utf8Encoder.encode(text)
let cryptoKey = await crypto.subtle.importKey(
'raw',
normalizedKey,
{name: 'AES-CBC'},
false,
['encrypt']
)
let ciphertext = await crypto.subtle.encrypt(
{name: 'AES-CBC', iv},
cryptoKey,
plaintext
)
let ctb64 = base64.encode(new Uint8Array(ciphertext))
let ivb64 = base64.encode(new Uint8Array(iv.buffer))
return `${ctb64}?iv=${ivb64}`
}
export async function decrypt(
privkey: string,
pubkey: string,
data: string
): Promise<string> {
let [ctb64, ivb64] = data.split('?iv=')
let key = secp256k1.getSharedSecret(privkey, '02' + pubkey)
let normalizedKey = getNormalizedX(key)
let cryptoKey = await crypto.subtle.importKey(
'raw',
normalizedKey,
{name: 'AES-CBC'},
false,
['decrypt']
)
let ciphertext = base64.decode(ctb64)
let iv = base64.decode(ivb64)
let plaintext = await crypto.subtle.decrypt(
{name: 'AES-CBC', iv},
cryptoKey,
ciphertext
)
let text = utf8Decoder.decode(plaintext)
return text
}
function getNormalizedX(key: Uint8Array): Uint8Array {
return key.slice(1, 33)
}

View File

@@ -1,28 +0,0 @@
import fetch from 'cross-fetch'
export async function searchDomain(domain, query = '') {
try {
let res = await (
await fetch(`https://${domain}/.well-known/nostr.json?name=${query}`)
).json()
return res.names
} catch (_) {
return []
}
}
export async function queryName(fullname) {
try {
let [name, domain] = fullname.split('@')
if (!domain) return null
let res = await (
await fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
).json()
return res.names && res.names[name]
} catch (_) {
return null
}
}

32
nip05.test.ts Normal file
View File

@@ -0,0 +1,32 @@
import fetch from 'node-fetch'
import {useFetchImplementation, queryProfile} from './nip05.ts'
test('fetch nip05 profiles', async () => {
useFetchImplementation(fetch)
let p1 = await queryProfile('jb55.com')
expect(p1!.pubkey).toEqual(
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
)
expect(p1!.relays).toEqual(['wss://relay.damus.io'])
let p2 = await queryProfile('jb55@jb55.com')
expect(p2!.pubkey).toEqual(
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
)
expect(p2!.relays).toEqual(['wss://relay.damus.io'])
let p3 = await queryProfile('_@fiatjaf.com')
expect(p3!.pubkey).toEqual(
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'
)
expect(p3!.relays).toEqual([
'wss://relay.nostr.bg',
'wss://nos.lol',
'wss://nostr-verified.wellorder.net',
'wss://nostr.zebedee.cloud',
'wss://eden.nostr.land',
'wss://nostr.milou.lol',
])
})

86
nip05.ts Normal file
View File

@@ -0,0 +1,86 @@
import {ProfilePointer} from './nip19.ts'
/**
* NIP-05 regex. The localpart is optional, and should be assumed to be `_` otherwise.
*
* - 0: full match
* - 1: name (optional)
* - 2: domain
*/
export const NIP05_REGEX = /^(?:([\w.+-]+)@)?([\w.-]+)$/
var _fetch: any
try {
_fetch = fetch
} catch {}
export function useFetchImplementation(fetchImplementation: any) {
_fetch = fetchImplementation
}
export async function searchDomain(
domain: string,
query = ''
): Promise<{[name: string]: string}> {
try {
let res = await (
await _fetch(`https://${domain}/.well-known/nostr.json?name=${query}`)
).json()
return res.names
} catch (_) {
return {}
}
}
export async function queryProfile(fullname: string): Promise<ProfilePointer | null> {
const match = fullname.match(NIP05_REGEX)
if (!match) return null
const [_, name = '_', domain] = match
try {
const res = await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
const { names, relays } = parseNIP05Result(await res.json())
const pubkey = names[name]
return pubkey ? { pubkey, relays: relays?.[pubkey] } : null
} catch (_e) {
return null
}
}
/** nostr.json result. */
export interface NIP05Result {
names: {
[name: string]: string
}
relays?: {
[pubkey: string]: string[]
}
}
/** Parse the nostr.json and throw if it's not valid. */
function parseNIP05Result(json: any): NIP05Result {
const result: NIP05Result = {
names: {},
}
for (const [name, pubkey] of Object.entries(json.names)) {
if (typeof name === 'string' && typeof pubkey === 'string') {
result.names[name] = pubkey
}
}
if (json.relays) {
result.relays = {}
for (const [pubkey, relays] of Object.entries(json.relays)) {
if (typeof pubkey === 'string' && Array.isArray(relays)) {
result.relays[pubkey] = relays.filter((relay: unknown) => typeof relay === 'string')
}
}
}
return result
}

View File

@@ -1,26 +0,0 @@
import {wordlist} from 'micro-bip39/wordlists/english'
import {
generateMnemonic,
mnemonicToSeedSync,
validateMnemonic
} from 'micro-bip39'
import {HDKey} from 'micro-bip32'
export function privateKeyFromSeed(seed) {
let root = HDKey.fromMasterSeed(Buffer.from(seed, 'hex'))
return Buffer.from(root.derive(`m/44'/1237'/0'/0/0`).privateKey).toString(
'hex'
)
}
export function seedFromWords(mnemonic) {
return Buffer.from(mnemonicToSeedSync(mnemonic)).toString('hex')
}
export function generateSeedWords() {
return generateMnemonic(wordlist)
}
export function validateWords(words) {
return validateMnemonic(words, wordlist)
}

18
nip06.test.ts Normal file
View File

@@ -0,0 +1,18 @@
import {privateKeyFromSeedWords} from './nip06.ts'
test('generate private key from a mnemonic', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const privateKey = privateKeyFromSeedWords(mnemonic)
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 = privateKeyFromSeedWords(mnemonic, passphrase)
expect(privateKey).toEqual(
'55a22b8203273d0aaf24c22c8fbe99608e70c524b17265641074281c8b978ae4'
)
})

26
nip06.ts Normal file
View File

@@ -0,0 +1,26 @@
import {bytesToHex} from '@noble/hashes/utils'
import {wordlist} from '@scure/bip39/wordlists/english'
import {
generateMnemonic,
mnemonicToSeedSync,
validateMnemonic
} from '@scure/bip39'
import {HDKey} from '@scure/bip32'
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')
return bytesToHex(privateKey)
}
export function generateSeedWords(): string {
return generateMnemonic(wordlist)
}
export function validateWords(words: string): boolean {
return validateMnemonic(words, wordlist)
}

351
nip10.test.ts Normal file
View File

@@ -0,0 +1,351 @@
import {parse} from './nip10.ts'
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(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(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(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(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(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.ts'
import type {EventPointer, ProfilePointer} from './nip19.ts'
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
}

7
nip13.test.ts Normal file
View File

@@ -0,0 +1,7 @@
import {getPow} from './nip13.ts'
test('identifies proof-of-work difficulty', async () => {
const id = '000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358'
const difficulty = getPow(id)
expect(difficulty).toEqual(21)
})

42
nip13.ts Normal file
View File

@@ -0,0 +1,42 @@
import {hexToBytes} from '@noble/hashes/utils'
/** Get POW difficulty from a Nostr hex ID. */
export function getPow(id: string): number {
return getLeadingZeroBits(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
}

112
nip18.test.ts Normal file
View File

@@ -0,0 +1,112 @@
import {finishEvent, Kind} from './event.ts'
import {getPublicKey} from './keys.ts'
import {finishRepostEvent, getRepostedEventPointer, getRepostedEvent} from './nip18.ts'
import {buildEvent} from './test-helpers.ts'
const relayUrl = 'https://relay.example.com'
describe('finishRepostEvent + getRepostedEventPointer + getRepostedEvent', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const repostedEvent = finishEvent(
{
kind: Kind.Text,
tags: [
['e', 'replied event id'],
['p', 'replied event pubkey']
],
content: 'Replied to a post',
created_at: 1617932115
},
privateKey
)
it('should create a signed event from a minimal template', () => {
const template = {
created_at: 1617932115
}
const event = finishRepostEvent(
template,
repostedEvent,
relayUrl,
privateKey
)
expect(event.kind).toEqual(Kind.Repost)
expect(event.tags).toEqual([
['e', repostedEvent.id, relayUrl],
['p', repostedEvent.pubkey]
])
expect(event.content).toEqual(JSON.stringify(repostedEvent))
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')
const repostedEventPointer = getRepostedEventPointer(event)
expect(repostedEventPointer!.id).toEqual(repostedEvent.id)
expect(repostedEventPointer!.author).toEqual(repostedEvent.pubkey)
expect(repostedEventPointer!.relays).toEqual([relayUrl])
const repostedEventFromContent = getRepostedEvent(event)
expect(repostedEventFromContent).toEqual(repostedEvent)
})
it('should create a signed event from a filled template', () => {
const template = {
tags: [['nonstandard', 'tag']],
content: '' as const,
created_at: 1617932115
}
const event = finishRepostEvent(
template,
repostedEvent,
relayUrl,
privateKey
)
expect(event.kind).toEqual(Kind.Repost)
expect(event.tags).toEqual([
['nonstandard', 'tag'],
['e', repostedEvent.id, relayUrl],
['p', repostedEvent.pubkey]
])
expect(event.content).toEqual('')
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')
const repostedEventPointer = getRepostedEventPointer(event)
expect(repostedEventPointer!.id).toEqual(repostedEvent.id)
expect(repostedEventPointer!.author).toEqual(repostedEvent.pubkey)
expect(repostedEventPointer!.relays).toEqual([relayUrl])
const repostedEventFromContent = getRepostedEvent(event)
expect(repostedEventFromContent).toEqual(undefined)
})
})
describe('getRepostedEventPointer', () => {
it('should parse an event with only an `e` tag', () => {
const event = buildEvent({
kind: Kind.Repost,
tags: [['e', 'reposted event id', relayUrl]],
})
const repostedEventPointer = getRepostedEventPointer(event)
expect(repostedEventPointer!.id).toEqual('reposted event id')
expect(repostedEventPointer!.author).toEqual(undefined)
expect(repostedEventPointer!.relays).toEqual([relayUrl])
})
})

97
nip18.ts Normal file
View File

@@ -0,0 +1,97 @@
import {Event, finishEvent, Kind, verifySignature} from './event.ts'
import {EventPointer} from './nip19.ts'
export type RepostEventTemplate = {
/**
* Pass only non-nip18 tags if you have to.
* Nip18 tags ('e' and 'p' tags pointing to the reposted event) will be added automatically.
*/
tags?: string[][]
/**
* Pass an empty string to NOT include the stringified JSON of the reposted event.
* Any other content will be ignored and replaced with the stringified JSON of the reposted event.
* @default Stringified JSON of the reposted event
*/
content?: '';
created_at: number
}
export function finishRepostEvent(
t: RepostEventTemplate,
reposted: Event<number>,
relayUrl: string,
privateKey: string,
): Event<Kind.Repost> {
return finishEvent({
kind: Kind.Repost,
tags: [
...(t.tags ?? []),
[ 'e', reposted.id, relayUrl ],
[ 'p', reposted.pubkey ],
],
content: t.content === '' ? '' : JSON.stringify(reposted),
created_at: t.created_at,
}, privateKey)
}
export function getRepostedEventPointer(event: Event<number>): undefined | EventPointer {
if (event.kind !== Kind.Repost) {
return undefined
}
let lastETag: undefined | string[]
let lastPTag: undefined | string[]
for (let i = event.tags.length - 1; i >= 0 && (lastETag === undefined || lastPTag === undefined); i--) {
const tag = event.tags[i]
if (tag.length >= 2) {
if (tag[0] === 'e' && lastETag === undefined) {
lastETag = tag
} else if (tag[0] === 'p' && lastPTag === undefined) {
lastPTag = tag
}
}
}
if (lastETag === undefined) {
return undefined
}
return {
id: lastETag[1],
relays: [ lastETag[2], lastPTag?.[2] ].filter((x): x is string => typeof x === 'string'),
author: lastPTag?.[1],
}
}
export type GetRepostedEventOptions = {
skipVerification?: boolean,
};
export function getRepostedEvent(event: Event<number>, { skipVerification }: GetRepostedEventOptions = {}): undefined | Event<number> {
const pointer = getRepostedEventPointer(event)
if (pointer === undefined || event.content === '') {
return undefined
}
let repostedEvent: undefined | Event<number>
try {
repostedEvent = JSON.parse(event.content) as Event<number>
} catch (error) {
return undefined
}
if (repostedEvent.id !== pointer.id) {
return undefined
}
if (!skipVerification && !verifySignature(repostedEvent)) {
return undefined
}
return repostedEvent
}

123
nip19.test.ts Normal file
View File

@@ -0,0 +1,123 @@
import {generatePrivateKey, getPublicKey} from './keys.ts'
import {
decode,
naddrEncode,
nprofileEncode,
npubEncode,
nrelayEncode,
nsecEncode,
type AddressPointer,
type ProfilePointer,
} from './nip19.ts'
test('encode and decode nsec', () => {
let sk = generatePrivateKey()
let nsec = nsecEncode(sk)
expect(nsec).toMatch(/nsec1\w+/)
let {type, data} = decode(nsec)
expect(type).toEqual('nsec')
expect(data).toEqual(sk)
})
test('encode and decode npub', () => {
let pk = getPublicKey(generatePrivateKey())
let npub = npubEncode(pk)
expect(npub).toMatch(/npub1\w+/)
let {type, data} = decode(npub)
expect(type).toEqual('npub')
expect(data).toEqual(pk)
})
test('encode and decode nprofile', () => {
let pk = getPublicKey(generatePrivateKey())
let relays = [
'wss://relay.nostr.example.mydomain.example.com',
'wss://nostr.banana.com'
]
let nprofile = nprofileEncode({pubkey: pk, relays})
expect(nprofile).toMatch(/nprofile1\w+/)
let {type, data} = decode(nprofile)
expect(type).toEqual('nprofile')
const pointer = data as ProfilePointer
expect(pointer.pubkey).toEqual(pk)
expect(pointer.relays).toContain(relays[0])
expect(pointer.relays).toContain(relays[1])
})
test('decode nprofile without relays', () => {
expect(
decode(
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 = naddrEncode({
pubkey: pk,
relays,
kind: 30023,
identifier: 'banana'
})
expect(naddr).toMatch(/naddr1\w+/)
let {type, data} = decode(naddr)
expect(type).toEqual('naddr')
const pointer = data as AddressPointer
expect(pointer.pubkey).toEqual(pk)
expect(pointer.relays).toContain(relays[0])
expect(pointer.relays).toContain(relays[1])
expect(pointer.kind).toEqual(30023)
expect(pointer.identifier).toEqual('banana')
})
test('decode naddr from habla.news', () => {
let {type, data} = decode(
'naddr1qq98yetxv4ex2mnrv4esygrl54h466tz4v0re4pyuavvxqptsejl0vxcmnhfl60z3rth2xkpjspsgqqqw4rsf34vl5'
)
expect(type).toEqual('naddr')
const pointer = data as AddressPointer
expect(pointer.pubkey).toEqual(
'7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194'
)
expect(pointer.kind).toEqual(30023)
expect(pointer.identifier).toEqual('references')
})
test('decode naddr from go-nostr with different TLV ordering', () => {
let {type, data} = decode(
'naddr1qqrxyctwv9hxzq3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqp65wqfwwaehxw309aex2mrp0yhxummnw3ezuetcv9khqmr99ekhjer0d4skjm3wv4uxzmtsd3jjucm0d5q3vamnwvaz7tmwdaehgu3wvfskuctwvyhxxmmd0zfmwx'
)
expect(type).toEqual('naddr')
const pointer = data as AddressPointer
expect(pointer.pubkey).toEqual(
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'
)
expect(pointer.relays).toContain(
'wss://relay.nostr.example.mydomain.example.com'
)
expect(pointer.relays).toContain('wss://nostr.banana.com')
expect(pointer.kind).toEqual(30023)
expect(pointer.identifier).toEqual('banana')
})
test('encode and decode nrelay', () => {
let url = 'wss://relay.nostr.example'
let nrelay = nrelayEncode(url)
expect(nrelay).toMatch(/nrelay1\w+/)
let {type, data} = decode(nrelay)
expect(type).toEqual('nrelay')
expect(data).toEqual(url)
})

219
nip19.ts Normal file
View File

@@ -0,0 +1,219 @@
import {bytesToHex, concatBytes, hexToBytes} from '@noble/hashes/utils'
import {bech32} from '@scure/base'
import {utf8Decoder, utf8Encoder} from './utils.ts'
const Bech32MaxSize = 5000
/**
* 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,}/
export type ProfilePointer = {
pubkey: string // hex
relays?: string[]
}
export type EventPointer = {
id: string // hex
relays?: string[]
author?: string
}
export type AddressPointer = {
identifier: string
pubkey: string
kind: number
relays?: string[]
}
type Prefixes = {
nprofile: ProfilePointer
nrelay: string
nevent: EventPointer
naddr: AddressPointer
nsec: string
npub: string
note: string
}
type DecodeValue<Prefix extends keyof Prefixes> = {
type: Prefix
data: Prefixes[Prefix]
}
export type DecodeResult = {
[P in keyof Prefixes]: DecodeValue<P>
}[keyof Prefixes]
export function decode<Prefix extends keyof Prefixes>(nip19: `${Prefix}1${string}`): DecodeValue<Prefix>
export function decode(nip19: string): DecodeResult
export function decode(nip19: string): DecodeResult {
let {prefix, words} = bech32.decode(nip19, Bech32MaxSize)
let data = new Uint8Array(bech32.fromWords(words))
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: 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')
return {
type: 'nevent',
data: {
id: bytesToHex(tlv[0][0]),
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
author: tlv[2]?.[0] ? bytesToHex(tlv[2][0]) : undefined
}
}
}
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')
return {
type: 'naddr',
data: {
identifier: utf8Decoder.decode(tlv[0][0]),
pubkey: bytesToHex(tlv[2][0]),
kind: parseInt(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: bytesToHex(data)}
default:
throw new Error(`unknown prefix ${prefix}`)
}
}
type TLV = {[t: number]: Uint8Array[]}
function parseTLV(data: Uint8Array): TLV {
let result: TLV = {}
let rest = data
while (rest.length > 0) {
let t = rest[0]
let l = rest[1]
if (!l) throw new Error(`malformed TLV ${t}`)
let v = rest.slice(2, 2 + l)
rest = rest.slice(2 + l)
if (v.length < l) throw new Error(`not enough data to read on TLV ${t}`)
result[t] = result[t] || []
result[t].push(v)
}
return result
}
export function nsecEncode(hex: string): `nsec1${string}` {
return encodeBytes('nsec', hex)
}
export function npubEncode(hex: string): `npub1${string}` {
return encodeBytes('npub', hex)
}
export function noteEncode(hex: string): `note1${string}` {
return encodeBytes('note', hex)
}
function encodeBech32<Prefix extends string>(prefix: Prefix, data: Uint8Array): `${Prefix}1${string}` {
let words = bech32.toWords(data)
return bech32.encode(prefix, words, Bech32MaxSize) as `${Prefix}1${string}`
}
function encodeBytes<Prefix extends string>(prefix: Prefix, hex: string): `${Prefix}1${string}` {
let data = hexToBytes(hex)
return encodeBech32(prefix, data)
}
export function nprofileEncode(profile: ProfilePointer): `nprofile1${string}` {
let data = encodeTLV({
0: [hexToBytes(profile.pubkey)],
1: (profile.relays || []).map(url => utf8Encoder.encode(url))
})
return encodeBech32('nprofile', data)
}
export function neventEncode(event: EventPointer): `nevent1${string}` {
let data = encodeTLV({
0: [hexToBytes(event.id)],
1: (event.relays || []).map(url => utf8Encoder.encode(url)),
2: event.author ? [hexToBytes(event.author)] : []
})
return encodeBech32('nevent', data)
}
export function naddrEncode(addr: AddressPointer): `naddr1${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: [hexToBytes(addr.pubkey)],
3: [new Uint8Array(kind)]
})
return encodeBech32('naddr', data)
}
export function nrelayEncode(url: string): `nrelay1${string}` {
let data = encodeTLV({
0: [utf8Encoder.encode(url)]
})
return encodeBech32('nrelay', data)
}
function encodeTLV(tlv: TLV): Uint8Array {
let entries: Uint8Array[] = []
Object.entries(tlv).forEach(([t, vs]) => {
vs.forEach(v => {
let entry = new Uint8Array(v.length + 2)
entry.set([parseInt(t)], 0)
entry.set([v.length], 1)
entry.set(v, 2)
entries.push(entry)
})
})
return concatBytes(...entries)
}

41
nip21.test.ts Normal file
View File

@@ -0,0 +1,41 @@
import {test as testRegex, parse} from './nip21.ts'
test('test()', () => {
expect(
testRegex(
'nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6'
)
).toBe(true)
expect(
testRegex(
'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
)
).toBe(true)
expect(
testRegex(
' nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6'
)
).toBe(false)
expect(testRegex('nostr:')).toBe(false)
expect(
testRegex(
'nostr:npub108pv4cg5ag52nQq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6'
)
).toBe(false)
expect(testRegex('gggggg')).toBe(false)
})
test('parse', () => {
const result = parse(
'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
)
expect(result).toEqual({
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
decoded: {
type: 'note',
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b'
}
})
})

33
nip21.ts Normal file
View File

@@ -0,0 +1,33 @@
import {BECH32_REGEX, decode, type DecodeResult} from './nip19.ts'
/** 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: DecodeResult
}
/** Parse and decode a Nostr URI. */
export function parse(uri: string): NostrURI {
const match = uri.match(new RegExp(`^${NOSTR_URI_REGEX.source}$`))
if (!match) throw new Error(`Invalid Nostr URI: ${uri}`)
return {
uri: match[0] as `nostr:${string}`,
value: match[1],
decoded: decode(match[1])
}
}

78
nip25.test.ts Normal file
View File

@@ -0,0 +1,78 @@
import {finishEvent, Kind} from './event.ts'
import {getPublicKey} from './keys.ts'
import {finishReactionEvent, getReactedEventPointer} from './nip25.ts'
describe('finishReactionEvent + getReactedEventPointer', () => {
const privateKey =
'd217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf'
const publicKey = getPublicKey(privateKey)
const reactedEvent = finishEvent(
{
kind: Kind.Text,
tags: [
['e', 'replied event id'],
['p', 'replied event pubkey']
],
content: 'Replied to a post',
created_at: 1617932115
},
privateKey
)
it('should create a signed event from a minimal template', () => {
const template = {
created_at: 1617932115
}
const event = finishReactionEvent(template, reactedEvent, privateKey)
expect(event.kind).toEqual(Kind.Reaction)
expect(event.tags).toEqual([
['e', 'replied event id'],
['p', 'replied event pubkey'],
['e', '0ecdbd4dba0652afb19e5f638257a41552a37995a4438ef63de658443f8d16b1'],
['p', '6af0f9de588f2c53cedcba26c5e2402e0d0aa64ec7b47c9f8d97b5bc562bab5f']
])
expect(event.content).toEqual('+')
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')
const reactedEventPointer = getReactedEventPointer(event)
expect(reactedEventPointer!.id).toEqual(reactedEvent.id)
expect(reactedEventPointer!.author).toEqual(reactedEvent.pubkey)
})
it('should create a signed event from a filled template', () => {
const template = {
tags: [['nonstandard', 'tag']],
content: '👍',
created_at: 1617932115
}
const event = finishReactionEvent(template, reactedEvent, privateKey)
expect(event.kind).toEqual(Kind.Reaction)
expect(event.tags).toEqual([
['nonstandard', 'tag'],
['e', 'replied event id'],
['p', 'replied event pubkey'],
['e', '0ecdbd4dba0652afb19e5f638257a41552a37995a4438ef63de658443f8d16b1'],
['p', '6af0f9de588f2c53cedcba26c5e2402e0d0aa64ec7b47c9f8d97b5bc562bab5f']
])
expect(event.content).toEqual('👍')
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')
const reactedEventPointer = getReactedEventPointer(event)
expect(reactedEventPointer!.id).toEqual(reactedEvent.id)
expect(reactedEventPointer!.author).toEqual(reactedEvent.pubkey)
})
})

69
nip25.ts Normal file
View File

@@ -0,0 +1,69 @@
import {Event, finishEvent, Kind} from './event.ts'
import type {EventPointer} from './nip19.ts'
export type ReactionEventTemplate = {
/**
* Pass only non-nip25 tags if you have to. Nip25 tags ('e' and 'p' tags from reacted event) will be added automatically.
*/
tags?: string[][]
/**
* @default '+'
*/
content?: string
created_at: number
}
export function finishReactionEvent(
t: ReactionEventTemplate,
reacted: Event<number>,
privateKey: string,
): Event<Kind.Reaction> {
const inheritedTags = reacted.tags.filter(
(tag) => tag.length >= 2 && (tag[0] === 'e' || tag[0] === 'p'),
)
return finishEvent({
...t,
kind: Kind.Reaction,
tags: [
...(t.tags ?? []),
...inheritedTags,
['e', reacted.id],
['p', reacted.pubkey],
],
content: t.content ?? '+',
}, privateKey)
}
export function getReactedEventPointer(event: Event<number>): undefined | EventPointer {
if (event.kind !== Kind.Reaction) {
return undefined
}
let lastETag: undefined | string[]
let lastPTag: undefined | string[]
for (let i = event.tags.length - 1; i >= 0 && (lastETag === undefined || lastPTag === undefined); i--) {
const tag = event.tags[i]
if (tag.length >= 2) {
if (tag[0] === 'e' && lastETag === undefined) {
lastETag = tag
} else if (tag[0] === 'p' && lastPTag === undefined) {
lastPTag = tag
}
}
}
if (lastETag === undefined || lastPTag === undefined) {
return undefined
}
return {
id: lastETag[1],
relays: [ lastETag[2], lastPTag[2] ].filter((x) => x !== undefined),
author: lastPTag[1],
}
}

105
nip26.test.ts Normal file
View File

@@ -0,0 +1,105 @@
import {getPublicKey, generatePrivateKey} from './keys.ts'
import {getDelegator, createDelegation} from './nip26.ts'
import {buildEvent} from './test-helpers.ts'
test('parse good delegation from NIP', async () => {
expect(
getDelegator({
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
pubkey:
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
created_at: 1660896109,
kind: 1,
tags: [
[
'delegation',
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e',
'kind=1&created_at>1640995200',
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1'
]
],
content: 'Hello world',
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6'
})
).toEqual('86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e')
})
test('parse bad delegations', async () => {
expect(
getDelegator({
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
pubkey:
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
created_at: 1660896109,
kind: 1,
tags: [
[
'delegation',
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42f',
'kind=1&created_at>1640995200',
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1'
]
],
content: 'Hello world',
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6'
})
).toEqual(null)
expect(
getDelegator({
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
pubkey:
'62903b1ff41559daf9ee98ef1ae67cc52f301bb5ce26d14baba3052f649c3f49',
created_at: 1660896109,
kind: 1,
tags: [
[
'delegation',
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e',
'kind=1&created_at>1740995200',
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1'
]
],
content: 'Hello world',
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6'
})
).toEqual(null)
expect(
getDelegator({
id: 'a080fd288b60ac2225ff2e2d815291bd730911e583e177302cc949a15dc2b2dc',
pubkey:
'62903b1ff41559daf9ee98ef1ae67c152f301bb5ce26d14baba3052f649c3f49',
created_at: 1660896109,
kind: 1,
tags: [
[
'delegation',
'86f0689bd48dcd19c67a19d994f938ee34f251d8c39976290955ff585f2db42e',
'kind=1&created_at>1640995200',
'c33c88ba78ec3c760e49db591ac5f7b129e3887c8af7729795e85a0588007e5ac89b46549232d8f918eefd73e726cb450135314bfda419c030d0b6affe401ec1'
]
],
content: 'Hello world',
sig: 'cd4a3cd20dc61dcbc98324de561a07fd23b3d9702115920c0814b5fb822cc5b7c5bcdaf3fa326d24ed50c5b9c8214d66c75bae34e3a84c25e4d122afccb66eb6'
})
).toEqual(null)
})
test('create and verify delegation', async () => {
let sk1 = generatePrivateKey()
let pk1 = getPublicKey(sk1)
let sk2 = generatePrivateKey()
let pk2 = getPublicKey(sk2)
let delegation = createDelegation(sk1, {pubkey: pk2, kind: 1})
expect(delegation).toHaveProperty('from', pk1)
expect(delegation).toHaveProperty('to', pk2)
expect(delegation).toHaveProperty('cond', 'kind=1')
let event = buildEvent({
kind: 1,
tags: [['delegation', delegation.from, delegation.cond, delegation.sig]],
pubkey: pk2,
})
expect(getDelegator(event)).toEqual(pk1)
})

92
nip26.ts Normal file
View File

@@ -0,0 +1,92 @@
import {schnorr} from '@noble/curves/secp256k1'
import {bytesToHex} from '@noble/hashes/utils'
import {sha256} from '@noble/hashes/sha256'
import {utf8Encoder} from './utils.ts'
import {getPublicKey} from './keys.ts'
import type {Event} from './event.ts'
export type Parameters = {
pubkey: string // the key to whom the delegation will be given
kind?: number
until?: number // delegation will only be valid until this date
since?: number // delegation will be valid from this date on
}
export type Delegation = {
from: string // the pubkey who signed the delegation
to: string // the pubkey that is allowed to use the delegation
cond: string // the string of conditions as they should be included in the event tag
sig: string
}
export function createDelegation(
privateKey: string,
parameters: Parameters
): Delegation {
let conditions = []
if ((parameters.kind || -1) >= 0) conditions.push(`kind=${parameters.kind}`)
if (parameters.until) conditions.push(`created_at<${parameters.until}`)
if (parameters.since) conditions.push(`created_at>${parameters.since}`)
let cond = conditions.join('&')
if (cond === '')
throw new Error('refusing to create a delegation without any conditions')
let sighash = sha256(
utf8Encoder.encode(`nostr:delegation:${parameters.pubkey}:${cond}`)
)
let sig = bytesToHex(
schnorr.sign(sighash, privateKey)
)
return {
from: getPublicKey(privateKey),
to: parameters.pubkey,
cond,
sig
}
}
export function getDelegator(event: Event<number>): string | null {
// find delegation tag
let tag = event.tags.find(tag => tag[0] === 'delegation' && tag.length >= 4)
if (!tag) return null
let pubkey = tag[1]
let cond = tag[2]
let sig = tag[3]
// check conditions
let conditions = cond.split('&')
for (let i = 0; i < conditions.length; i++) {
let [key, operator, value] = conditions[i].split(/\b/)
// the supported conditions are just 'kind' and 'created_at' for now
if (key === 'kind' && operator === '=' && event.kind === parseInt(value))
continue
else if (
key === 'created_at' &&
operator === '<' &&
event.created_at < parseInt(value)
)
continue
else if (
key === 'created_at' &&
operator === '>' &&
event.created_at > parseInt(value)
)
continue
else return null // invalid condition
}
// check signature
let sighash = sha256(
utf8Encoder.encode(`nostr:delegation:${event.pubkey}:${cond}`)
)
if (!schnorr.verify(sig, sighash, pubkey)) return null
return pubkey
}

67
nip27.test.ts Normal file
View File

@@ -0,0 +1,67 @@
import {matchAll, replaceAll} from './nip27.ts'
test('matchAll', () => {
const result = 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('matchAll with an invalid nip19', () => {
const result = matchAll(
'Hello nostr:npub129tvj896hqqkljerxkccpj9flshwnw999v9uwn9lfmwlj8vnzwgq9y5llnpub1rujdpkd8mwezrvpqd2rx2zphfaztqrtsfg6w3vdnlj!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
)
expect([...result]).toEqual([
{
decoded: {
data: '46d731680add2990efe1cc619dc9b8014feeb23261ab9dee50e9d11814de5a2b',
type: 'note'
},
end: 193,
start: 124,
uri: 'nostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky',
value: 'note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
}
])
})
test('replaceAll', () => {
const content =
'Hello nostr:npub108pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmq9uyev6!\n\nnostr:note1gmtnz6q2m55epmlpe3semjdcq987av3jvx4emmjsa8g3s9x7tg4sclreky'
const result = 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')
})

66
nip27.ts Normal file
View File

@@ -0,0 +1,66 @@
import {decode} from './nip19.ts'
import {NOSTR_URI_REGEX, type NostrURI} from './nip21.ts'
/** Regex to find NIP-21 URIs inside event content. */
export const regex = () => new RegExp(`\\b${NOSTR_URI_REGEX.source}\\b`, 'g')
/** Match result for a Nostr URI in event content. */
export interface NostrURIMatch extends 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) {
try {
const [uri, value] = match
yield {
uri: uri as `nostr:${string}`,
value,
decoded: decode(value),
start: match.index!,
end: match.index! + uri.length
}
} catch (_e) {
// do nothing
}
}
}
/**
* 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: NostrURI) => string
): string {
return content.replaceAll(regex(), (uri, value: string) => {
return replacer({
uri: uri as `nostr:${string}`,
value,
decoded: decode(value)
})
})
}

14
nip39.test.ts Normal file
View File

@@ -0,0 +1,14 @@
import fetch from 'node-fetch'
import {useFetchImplementation, validateGithub} from './nip39.ts'
test('validate github claim', async () => {
useFetchImplementation(fetch)
let result = await 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
}
}

26
nip42.test.ts Normal file
View File

@@ -0,0 +1,26 @@
import 'websocket-polyfill'
import {finishEvent} from './event.ts'
import {generatePrivateKey} from './keys.ts'
import {authenticate} from './nip42.ts'
import {relayInit} from './relay.ts'
test('auth flow', () => {
const relay = relayInit('wss://nostr.kollider.xyz')
relay.connect()
const sk = generatePrivateKey()
return new Promise<void>((resolve) => {
relay.on('auth', async challenge => {
await expect(
authenticate({
challenge,
relay,
sign: (e) => finishEvent(e, sk)
})
).rejects.toBeTruthy()
relay.close()
resolve()
})
})
})

42
nip42.ts Normal file
View File

@@ -0,0 +1,42 @@
import {Kind, type EventTemplate, type Event} from './event.ts'
import {Relay} from './relay.ts'
/**
* 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: <K extends number = number>(e: EventTemplate<K>) => Promise<Event<K>> | Event<K>
}): 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)
})
})
}

340
nip57.test.ts Normal file
View File

@@ -0,0 +1,340 @@
import {finishEvent} from './event.ts'
import {getPublicKey, generatePrivateKey} from './keys.ts'
import {
getZapEndpoint,
makeZapReceipt,
makeZapRequest,
useFetchImplementation,
validateZapRequest,
} from './nip57.ts'
import {buildEvent} from './test-helpers.ts'
describe('getZapEndpoint', () => {
test('returns null if neither lud06 nor lud16 is present', async () => {
const metadata = buildEvent({kind: 0, content: '{}'})
const result = await getZapEndpoint(metadata)
expect(result).toBeNull()
})
test('returns null if fetch fails', async () => {
const fetchImplementation = jest.fn(() => Promise.reject(new Error()))
useFetchImplementation(fetchImplementation)
const metadata = buildEvent({kind: 0, content: '{"lud16": "name@domain"}'})
const result = await 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})})
)
useFetchImplementation(fetchImplementation)
const metadata = buildEvent({kind: 0, content: '{"lud16": "name@domain"}'})
const result = await 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'
})
})
)
useFetchImplementation(fetchImplementation)
const metadata = buildEvent({kind: 0, content: '{"lud16": "name@domain"}'})
const result = await 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(() =>
// @ts-expect-error
makeZapRequest({
profile: 'profile',
event: null,
relays: [],
comment: ''
})
).toThrow()
})
test('throws an error if profile is not given', () => {
expect(() =>
// @ts-expect-error
makeZapRequest({
event: null,
amount: 100,
relays: [],
comment: ''
})
).toThrow()
})
test('returns a valid Zap request', () => {
const result = 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(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(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(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(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(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(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(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(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 = 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 = 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')
})
})

147
nip57.ts Normal file
View File

@@ -0,0 +1,147 @@
import {bech32} from '@scure/base'
import {
Kind,
validateEvent,
verifySignature,
type Event,
type EventTemplate,
} from './event.ts'
import {utf8Decoder} from './utils.ts'
var _fetch: any
try {
_fetch = fetch
} catch {}
export function useFetchImplementation(fetchImplementation: any) {
_fetch = fetchImplementation
}
export async function getZapEndpoint(
metadata: Event<Kind.Metadata>
): 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<Kind.ZapRequest> {
if (!amount) throw new Error('amount not given')
if (!profile) throw new Error('profile not given')
let zr: EventTemplate<Kind.ZapRequest> = {
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
bolt11: string
paidAt: Date
}): EventTemplate<Kind.Zap> {
let zr: Event<Kind.ZapRequest> = JSON.parse(zapRequest)
let tagsFromZapRequest = zr.tags.filter(
([t]) => t === 'e' || t === 'p' || t === 'a'
)
let zap: EventTemplate<Kind.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
}

139
nip98.test.ts Normal file
View File

@@ -0,0 +1,139 @@
import {base64} from '@scure/base'
import {getToken, validateToken} from './nip98.ts'
import {Event, Kind, finishEvent} from './event.ts'
import {utf8Decoder} from './utils.ts'
import {generatePrivateKey, getPublicKey} from './keys.ts'
const sk = generatePrivateKey()
describe('getToken', () => {
test('getToken GET returns without authorization scheme', async () => {
let result = await getToken('http://test.com', 'get', e =>
finishEvent(e, sk)
)
const decodedResult: Event = JSON.parse(
utf8Decoder.decode(base64.decode(result))
)
expect(decodedResult.created_at).toBeGreaterThan(0)
expect(decodedResult.content).toBe('')
expect(decodedResult.kind).toBe(Kind.HttpAuth)
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
expect(decodedResult.tags).toStrictEqual([
['u', 'http://test.com'],
['method', 'get']
])
})
test('getToken POST returns token without authorization scheme', async () => {
let result = await getToken('http://test.com', 'post', e =>
finishEvent(e, sk)
)
const decodedResult: Event = JSON.parse(
utf8Decoder.decode(base64.decode(result))
)
expect(decodedResult.created_at).toBeGreaterThan(0)
expect(decodedResult.content).toBe('')
expect(decodedResult.kind).toBe(Kind.HttpAuth)
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
expect(decodedResult.tags).toStrictEqual([
['u', 'http://test.com'],
['method', 'post']
])
})
test('getToken GET returns token WITH authorization scheme', async () => {
const authorizationScheme = 'Nostr '
let result = await getToken(
'http://test.com',
'post',
e => finishEvent(e, sk),
true
)
expect(result.startsWith(authorizationScheme)).toBe(true)
const decodedResult: Event = JSON.parse(
utf8Decoder.decode(base64.decode(result.replace(authorizationScheme, '')))
)
expect(decodedResult.created_at).toBeGreaterThan(0)
expect(decodedResult.content).toBe('')
expect(decodedResult.kind).toBe(Kind.HttpAuth)
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
expect(decodedResult.tags).toStrictEqual([
['u', 'http://test.com'],
['method', 'post']
])
})
test('getToken unknown method throws an error', async () => {
const result = getToken('http://test.com', 'fake', e => finishEvent(e, sk))
await expect(result).rejects.toThrow(Error)
})
test('getToken missing loginUrl throws an error', async () => {
const result = getToken('', 'get', e => finishEvent(e, sk))
await expect(result).rejects.toThrow(Error)
})
test('getToken missing httpMethod throws an error', async () => {
const result = getToken('http://test.com', '', e => finishEvent(e, sk))
await expect(result).rejects.toThrow(Error)
})
})
describe('validateToken', () => {
test('validateToken returns true for valid token without authorization scheme', async () => {
const validToken = await getToken('http://test.com', 'get', e =>
finishEvent(e, sk)
)
const result = await validateToken(validToken, 'http://test.com', 'get')
expect(result).toBe(true)
})
test('validateToken returns true for valid token with authorization scheme', async () => {
const validToken = await getToken(
'http://test.com',
'get',
e => finishEvent(e, sk),
true
)
const result = await validateToken(validToken, 'http://test.com', 'get')
expect(result).toBe(true)
})
test('validateToken throws an error for invalid token', async () => {
const result = validateToken('fake', 'http://test.com', 'get')
await expect(result).rejects.toThrow(Error)
})
test('validateToken throws an error for missing token', async () => {
const result = validateToken('', 'http://test.com', 'get')
await expect(result).rejects.toThrow(Error)
})
test('validateToken throws an error for a wrong url', async () => {
const validToken = await getToken('http://test.com', 'get', e =>
finishEvent(e, sk)
)
const result = validateToken(validToken, 'http://wrong-test.com', 'get')
await expect(result).rejects.toThrow(Error)
})
test('validateToken throws an error for a wrong method', async () => {
const validToken = await getToken('http://test.com', 'get', e =>
finishEvent(e, sk)
)
const result = validateToken(validToken, 'http://test.com', 'post')
await expect(result).rejects.toThrow(Error)
})
})

112
nip98.ts Normal file
View File

@@ -0,0 +1,112 @@
import {base64} from '@scure/base'
import {
Event,
EventTemplate,
Kind,
getBlankEvent,
verifySignature
} from './event'
import {utf8Decoder, utf8Encoder} from './utils'
enum HttpMethod {
Get = 'get',
Post = 'post'
}
const _authorizationScheme = 'Nostr '
/**
* Generate token for NIP-98 flow.
*
* @example
* const sign = window.nostr.signEvent
* await getToken('https://example.com/login', 'post', sign, true)
*/
export async function getToken(
loginUrl: string,
httpMethod: HttpMethod | string,
sign: <K extends number = number>(
e: EventTemplate<K>
) => Promise<Event<K>> | Event<K>,
includeAuthorizationScheme: boolean = false
): Promise<string> {
if (!loginUrl || !httpMethod)
throw new Error('Missing loginUrl or httpMethod')
if (httpMethod !== HttpMethod.Get && httpMethod !== HttpMethod.Post)
throw new Error('Unknown httpMethod')
const event = getBlankEvent(Kind.HttpAuth)
event.tags = [
['u', loginUrl],
['method', httpMethod]
]
event.created_at = Math.round(new Date().getTime() / 1000)
const signedEvent = await sign(event)
const authorizationScheme = includeAuthorizationScheme
? _authorizationScheme
: ''
return (
authorizationScheme +
base64.encode(utf8Encoder.encode(JSON.stringify(signedEvent)))
)
}
/**
* Validate token for NIP-98 flow.
*
* @example
* await validateToken('Nostr base64token', 'https://example.com/login', 'post')
*/
export async function validateToken(
token: string,
url: string,
method: string
): Promise<boolean> {
if (!token) {
throw new Error('Missing token')
}
token = token.replace(_authorizationScheme, '')
const eventB64 = utf8Decoder.decode(base64.decode(token))
if (!eventB64 || eventB64.length === 0 || !eventB64.startsWith('{')) {
throw new Error('Invalid token')
}
const event = JSON.parse(eventB64) as Event
if (!event) {
throw new Error('Invalid nostr event')
}
if (!verifySignature(event)) {
throw new Error('Invalid nostr event, signature invalid')
}
if (event.kind !== Kind.HttpAuth) {
throw new Error('Invalid nostr event, kind invalid')
}
if (!event.created_at) {
throw new Error('Invalid nostr event, created_at invalid')
}
// Event must be less than 60 seconds old
if (Math.round(new Date().getTime() / 1000) - event.created_at > 60) {
throw new Error('Invalid nostr event, expired')
}
const urlTag = event.tags.find(t => t[0] === 'u')
if (urlTag?.length !== 1 && urlTag?.[1] !== url) {
throw new Error('Invalid nostr event, url tag invalid')
}
const methodTag = event.tags.find(t => t[0] === 'method')
if (
methodTag?.length !== 1 &&
methodTag?.[1].toLowerCase() !== method.toLowerCase()
) {
throw new Error('Invalid nostr event, method tag invalid')
}
return true
}

View File

@@ -1,44 +1,61 @@
{
"name": "nostr-tools",
"version": "0.23.4",
"version": "1.13.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/esm/nostr.mjs",
"exports": {
"import": "./lib/esm/nostr.mjs",
"require": "./lib/nostr.cjs.js",
"types": "./lib/index.d.ts"
},
"license": "Unlicense",
"dependencies": {
"@noble/hashes": "^0.5.7",
"@noble/secp256k1": "^1.5.2",
"browserify-cipher": ">=1",
"buffer": ">=5",
"create-hash": "^1.2.0",
"cross-fetch": "^3.1.4",
"micro-bip32": "^0.1.0",
"micro-bip39": "^0.1.3",
"websocket-polyfill": "^0.0.3"
"@noble/curves": "1.1.0",
"@noble/hashes": "1.3.1",
"@scure/base": "1.1.1",
"@scure/bip32": "1.3.1",
"@scure/bip39": "1.2.1"
},
"keywords": [
"decentralization",
"twitter",
"p2p",
"mastodon",
"ssb",
"social",
"unstoppable",
"censorship",
"censorship-resistance",
"client"
"client",
"nostr"
],
"devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "^0.1.1",
"esbuild": "^0.14.38",
"esbuild-plugin-alias": "^0.2.1",
"eslint": "^8.5.0",
"eslint-plugin-babel": "^5.3.1",
"events": "^3.3.0",
"readable-stream": "^3.6.0"
},
"scripts": {
"prepublish": "node build.js"
"build": "node build",
"format": "prettier --plugin-search-dir . --write .",
"test": "jest"
},
"devDependencies": {
"@types/jest": "^29.5.1",
"@types/node": "^18.13.0",
"@types/node-fetch": "^2.6.3",
"@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.40.0",
"eslint-plugin-babel": "^5.3.1",
"eslint-plugin-jest": "^27.2.1",
"esm-loader-typescript": "^1.0.3",
"events": "^3.3.0",
"jest": "^29.5.0",
"node-fetch": "^2.6.9",
"prettier": "^2.8.4",
"ts-jest": "^29.1.0",
"tsd": "^0.22.0",
"typescript": "^5.0.4",
"websocket-polyfill": "^0.0.3"
}
}

206
pool.js
View File

@@ -1,206 +0,0 @@
import {getEventHash, verifySignature, signEvent} from './event'
import {relayConnect, normalizeRelayURL} from './relay'
export function relayPool() {
var globalPrivateKey
var globalSigningFunction
const poolPolicy = {
// setting this to a number will cause events to be published to a random
// set of relays only, instead of publishing to all relays all the time
randomChoice: null,
// setting this to true will cause .publish() calls to wait until the event has
// been published -- or at least attempted to be published -- to all relays
wait: false
}
const relays = {}
const noticeCallbacks = []
function propagateNotice(notice, relayURL) {
for (let i = 0; i < noticeCallbacks.length; i++) {
let {relay} = relays[relayURL]
noticeCallbacks[i](notice, relay)
}
}
const activeSubscriptions = {}
const sub = ({cb, filter, beforeSend}, id) => {
if (!id) id = Math.random().toString().slice(2)
const subControllers = Object.fromEntries(
Object.values(relays)
.filter(({policy}) => policy.read)
.map(({relay}) => [
relay.url,
relay.sub({cb: event => cb(event, relay.url), filter, beforeSend}, id)
])
)
const activeCallback = cb
const activeFilters = filter
const activeBeforeSend = beforeSend
const unsub = () => {
Object.values(subControllers).forEach(sub => sub.unsub())
delete activeSubscriptions[id]
}
const sub = ({
cb = activeCallback,
filter = activeFilters,
beforeSend = activeBeforeSend
}) => {
Object.entries(subControllers).map(([relayURL, sub]) => [
relayURL,
sub.sub({cb: event => cb(event, relayURL), filter, beforeSend}, id)
])
return activeSubscriptions[id]
}
const addRelay = relay => {
subControllers[relay.url] = relay.sub(
{cb: event => cb(event, relay.url), filter, beforeSend},
id
)
return activeSubscriptions[id]
}
const removeRelay = relayURL => {
if (relayURL in subControllers) {
subControllers[relayURL].unsub()
if (Object.keys(subControllers).length === 0) unsub()
}
return activeSubscriptions[id]
}
activeSubscriptions[id] = {
sub,
unsub,
addRelay,
removeRelay
}
return activeSubscriptions[id]
}
return {
sub,
relays,
setPrivateKey(privateKey) {
globalPrivateKey = privateKey
},
registerSigningFunction(fn) {
globalSigningFunction = fn
},
setPolicy(key, value) {
poolPolicy[key] = value
},
addRelay(url, policy = {read: true, write: true}) {
let relayURL = normalizeRelayURL(url)
if (relayURL in relays) return
let relay = relayConnect(url, notice => {
propagateNotice(notice, relayURL)
})
relays[relayURL] = {relay, policy}
if (policy.read) {
Object.values(activeSubscriptions).forEach(subscription =>
subscription.addRelay(relay)
)
}
return relay
},
removeRelay(url) {
let relayURL = normalizeRelayURL(url)
let data = relays[relayURL]
if (!data) return
let {relay} = data
Object.values(activeSubscriptions).forEach(subscription =>
subscription.removeRelay(relay)
)
relay.close()
delete relays[relayURL]
},
onNotice(cb) {
noticeCallbacks.push(cb)
},
offNotice(cb) {
let index = noticeCallbacks.indexOf(cb)
if (index !== -1) noticeCallbacks.splice(index, 1)
},
async publish(event, statusCallback) {
event.id = getEventHash(event)
if (!event.sig) {
event.tags = event.tags || []
if (globalPrivateKey) {
event.sig = await signEvent(event, globalPrivateKey)
} else if (globalSigningFunction) {
event.sig = await globalSigningFunction(event)
if (!event.sig) {
// abort here
return
} else {
// check
if (!(await verifySignature(event)))
throw new Error(
'signature provided by custom signing function is invalid.'
)
}
} else {
throw new Error(
"can't publish unsigned event. either sign this event beforehand, provide a signing function or pass a private key while initializing this relay pool so it can be signed automatically."
)
}
}
let writeable = Object.values(relays)
.filter(({policy}) => policy.write)
.sort(() => Math.random() - 0.5) // random
let maxTargets = poolPolicy.randomChoice
? poolPolicy.randomChoice
: writeable.length
let successes = 0
if (poolPolicy.wait) {
for (let i = 0; i < writeable.length; i++) {
let {relay} = writeable[i]
try {
await new Promise(async (resolve, reject) => {
try {
await relay.publish(event, status => {
if (statusCallback) statusCallback(status, relay.url)
resolve()
})
} catch (err) {
if (statusCallback) statusCallback(-1, relay.url)
}
})
successes++
if (successes >= maxTargets) {
break
}
} catch (err) {
/***/
}
}
} else {
writeable.forEach(async ({relay}) => {
let callback = statusCallback
? status => statusCallback(status, relay.url)
: null
relay.publish(event, callback)
})
}
return event
}
}
}

140
pool.test.ts Normal file
View File

@@ -0,0 +1,140 @@
import 'websocket-polyfill'
import {finishEvent, type Event} from './event.ts'
import {generatePrivateKey, getPublicKey} from './keys.ts'
import {SimplePool} from './pool.ts'
let pool = new SimplePool()
let relays = [
'wss://relay.damus.io/',
'wss://relay.nostr.bg/',
'wss://nostr.fmt.wiz.biz/',
'wss://relay.nostr.band/',
'wss://nos.lol/'
]
afterAll(() => {
pool.close([
...relays,
'wss://nostr.wine',
'wss://offchain.pub',
'wss://eden.nostr.land'
])
})
test('removing duplicates when querying', async () => {
let priv = generatePrivateKey()
let pub = getPublicKey(priv)
let sub = pool.sub(relays, [{authors: [pub]}])
let received: Event[] = []
sub.on('event', event => {
// this should be called only once even though we're listening
// to multiple relays because the events will be catched and
// deduplicated efficiently (without even being parsed)
received.push(event)
})
let event = finishEvent({
created_at: Math.round(Date.now() / 1000),
content: 'test',
kind: 22345,
tags: []
}, priv)
pool.publish(relays, event)
await new Promise(resolve => setTimeout(resolve, 1500))
expect(received).toHaveLength(1)
})
test('same with double querying', async () => {
let priv = generatePrivateKey()
let pub = getPublicKey(priv)
let sub1 = pool.sub(relays, [{authors: [pub]}])
let sub2 = pool.sub(relays, [{authors: [pub]}])
let received: Event[] = []
sub1.on('event', event => {
received.push(event)
})
sub2.on('event', event => {
received.push(event)
})
let event = finishEvent({
created_at: Math.round(Date.now() / 1000),
content: 'test2',
kind: 22346,
tags: []
}, priv)
pool.publish(relays, event)
await new Promise(resolve => setTimeout(resolve, 1500))
expect(received).toHaveLength(2)
})
test('get()', async () => {
let event = await pool.get(relays, {
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027']
})
expect(event).toHaveProperty(
'id',
'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'
)
})
test('list()', async () => {
let events = await pool.list(
[...relays, 'wss://offchain.pub', 'wss://eden.nostr.land'],
[
{
authors: [
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'
],
kinds: [1],
limit: 2
}
]
)
// the actual received number will be greater than 2, but there will be no duplicates
expect(events.length).toEqual(
events
.map(evt => evt.id)
// @ts-ignore ???
.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)
})
test('seenOnEnabled: false', async () => {
const poolWithoutSeenOn = new SimplePool({seenOnEnabled: false})
const event = await poolWithoutSeenOn.get(relays, {
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027']
})
expect(event).toHaveProperty(
'id',
'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'
)
const relaysForEvent = poolWithoutSeenOn.seenOn(event!.id)
expect(relaysForEvent).toHaveLength(0)
})

208
pool.ts Normal file
View File

@@ -0,0 +1,208 @@
import {
relayInit,
type Pub,
type Relay,
type Sub,
type SubscriptionOptions,
} from './relay.ts'
import {normalizeURL} from './utils.ts'
import type {Event} from './event.ts'
import type {Filter} from './filter.ts'
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
private eoseSubTimeout: number
private getTimeout: number
private seenOnEnabled: boolean = true
constructor(options: {eoseSubTimeout?: number; getTimeout?: number; seenOnEnabled?: boolean} = {}) {
this._conn = {}
this.eoseSubTimeout = options.eoseSubTimeout || 3400
this.getTimeout = options.getTimeout || 3400
this.seenOnEnabled = options.seenOnEnabled !== false
}
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)
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<K extends number = number>(relays: string[], filters: Filter<K>[], opts?: SubscriptionOptions): Sub<K> {
let _knownIds: Set<string> = new Set()
let modifiedOpts = {...(opts || {})}
modifiedOpts.alreadyHaveEvent = (id, url) => {
if (opts?.alreadyHaveEvent?.(id, url)) {
return true
}
if (this.seenOnEnabled) {
let set = this._seenOn[id] || new Set()
set.add(url)
this._seenOn[id] = set
}
return _knownIds.has(id)
}
let subs: Sub[] = []
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()
}, this.eoseSubTimeout)
relays.forEach(async 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) => {
_knownIds.add(event.id as string)
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()
}
}
})
let greaterSub: Sub = {
sub(filters, opts) {
subs.forEach(sub => sub.sub(filters, opts))
return greaterSub
},
unsub() {
subs.forEach(sub => sub.unsub())
},
on(type, cb) {
if (type === 'event') {
eventListeners.add(cb)
} else if (type === 'eose') {
eoseListeners.add(cb as () => void | Promise<void>)
}
},
off(type, cb) {
if (type === 'event') {
eventListeners.delete(cb)
} else if (type === 'eose')
eoseListeners.delete(cb as () => void | Promise<void>)
}
}
return greaterSub
}
get<K extends number = number>(
relays: string[],
filter: Filter<K>,
opts?: SubscriptionOptions
): Promise<Event<K> | null> {
return new Promise(resolve => {
let sub = this.sub(relays, [filter], opts)
let timeout = setTimeout(() => {
sub.unsub()
resolve(null)
}, this.getTimeout)
sub.on('event', (event) => {
resolve(event)
clearTimeout(timeout)
sub.unsub()
})
})
}
list<K extends number = number>(
relays: string[],
filters: Filter<K>[],
opts?: SubscriptionOptions
): Promise<Event<K>[]> {
return new Promise(resolve => {
let events: Event<K>[] = []
let sub = this.sub(relays, filters, opts)
sub.on('event', (event) => {
events.push(event)
})
// we can rely on an eose being emitted here because pool.sub() will fake one
sub.on('eose', () => {
sub.unsub()
resolve(events)
})
})
}
publish(relays: string[], event: Event<number>): 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() {}}
}
})
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?.() || [])
}
}

61
references.test.ts Normal file
View File

@@ -0,0 +1,61 @@
import {parseReferences} from './references.ts'
import {buildEvent} from './test-helpers.ts'
test('parse mentions', () => {
let evt = buildEvent({
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
}
}
])
})

110
references.ts Normal file
View File

@@ -0,0 +1,110 @@
import {
decode,
type AddressPointer,
type ProfilePointer,
type EventPointer,
} from './nip19.ts'
import type {Event} from './event.ts'
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
}

192
relay.js
View File

@@ -1,192 +0,0 @@
/* global WebSocket */
import 'websocket-polyfill'
import {verifySignature, validateEvent} from './event'
import {matchFilters} from './filter'
export function normalizeRelayURL(url) {
let [host, ...qs] = url.trim().split('?')
if (host.slice(0, 4) === 'http') host = 'ws' + host.slice(4)
if (host.slice(0, 2) !== 'ws') host = 'wss://' + host
if (host.length && host[host.length - 1] === '/') host = host.slice(0, -1)
return [host, ...qs].join('?')
}
export function relayConnect(url, onNotice = () => {}, onError = () => {}) {
url = normalizeRelayURL(url)
var ws, resolveOpen, untilOpen, wasClosed
var openSubs = {}
let attemptNumber = 1
let nextAttemptSeconds = 1
function resetOpenState() {
untilOpen = new Promise(resolve => {
resolveOpen = resolve
})
}
var channels = {}
function connect() {
ws = new WebSocket(url)
ws.onopen = () => {
console.log('connected to', url)
resolveOpen()
// restablish old subscriptions
if (wasClosed) {
wasClosed = false
for (let channel in openSubs) {
let filters = openSubs[channel]
let cb = channels[channel]
sub({cb, filter: filters}, channel)
}
}
}
ws.onerror = err => {
console.log('error connecting to relay', url)
onError(err)
}
ws.onclose = () => {
resetOpenState()
attemptNumber++
nextAttemptSeconds += attemptNumber ** 3
if (nextAttemptSeconds > 14400) {
nextAttemptSeconds = 14400 // 4 hours
}
console.log(
`relay ${url} connection closed. reconnecting in ${nextAttemptSeconds} seconds.`
)
setTimeout(async () => {
try {
connect()
} catch (err) {}
}, nextAttemptSeconds * 1000)
wasClosed = true
}
ws.onmessage = async e => {
var data
try {
data = JSON.parse(e.data)
} catch (err) {
data = e.data
}
if (data.length > 1) {
if (data[0] === 'NOTICE') {
if (data.length < 2) return
console.log('message from relay ' + url + ': ' + data[1])
onNotice(data[1])
return
}
if (data[0] === 'EVENT') {
if (data.length < 3) return
let channel = data[1]
let event = data[2]
if (
validateEvent(event) &&
verifySignature(event) &&
channels[channel] &&
matchFilters(openSubs[channel], event)
) {
channels[channel](event)
}
return
}
}
}
}
resetOpenState()
try {
connect()
} catch (err) {}
async function trySend(params) {
let msg = JSON.stringify(params)
await untilOpen
ws.send(msg)
}
const sub = (
{cb, filter, beforeSend},
channel = Math.random().toString().slice(2)
) => {
var filters = []
if (Array.isArray(filter)) {
filters = filter
} else {
filters.push(filter)
}
if (beforeSend) {
const beforeSendResult = beforeSend({filter, relay: url, channel})
filters = beforeSendResult.filter
}
trySend(['REQ', channel, ...filters])
channels[channel] = cb
openSubs[channel] = filters
const activeCallback = cb
const activeFilters = filters
const activeBeforeSend = beforeSend
return {
sub: ({
cb = activeCallback,
filter = activeFilters,
beforeSend = activeBeforeSend
}) => sub({cb, filter, beforeSend}, channel),
unsub: () => {
delete openSubs[channel]
delete channels[channel]
trySend(['CLOSE', channel])
}
}
}
return {
url,
sub,
async publish(event, statusCallback) {
try {
await trySend(['EVENT', event])
if (statusCallback) {
statusCallback(0)
let {unsub} = sub(
{
cb: () => {
statusCallback(1)
unsub()
clearTimeout(willUnsub)
},
filter: {ids: [event.id]}
},
`monitor-${event.id.slice(0, 5)}`
)
let willUnsub = setTimeout(unsub, 5000)
}
} catch (err) {
if (statusCallback) statusCallback(-1)
}
},
close() {
ws.close()
},
get status() {
return ws.readyState
}
}
}

132
relay.test.ts Normal file
View File

@@ -0,0 +1,132 @@
import 'websocket-polyfill'
import {finishEvent} from './event.ts'
import {generatePrivateKey, getPublicKey} from './keys.ts'
import {relayInit} from './relay.ts'
let relay = relayInit('wss://relay.damus.io/')
beforeAll(() => {
relay.connect()
})
afterAll(() => {
relay.close()
})
test('connectivity', () => {
return expect(
new Promise(resolve => {
relay.on('connect', () => {
resolve(true)
})
relay.on('error', () => {
resolve(false)
})
})
).resolves.toBe(true)
})
test('querying', async () => {
var resolve1: (value: boolean) => void
var resolve2: (value: boolean) => void
let sub = relay.sub([
{
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027']
}
])
sub.on('event', event => {
expect(event).toHaveProperty(
'id',
'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'
)
resolve1(true)
})
sub.on('eose', () => {
resolve2(true)
})
let [t1, t2] = await Promise.all([
new Promise<boolean>(resolve => {
resolve1 = resolve
}),
new Promise<boolean>(resolve => {
resolve2 = resolve
})
])
expect(t1).toEqual(true)
expect(t2).toEqual(true)
})
test('get()', async () => {
let event = await relay.get({
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027']
})
expect(event).toHaveProperty(
'id',
'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'
)
})
test('list()', async () => {
let events = await relay.list([
{
authors: [
'3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'
],
kinds: [1],
limit: 2
}
])
expect(events.length).toEqual(2)
})
test('listening (twice) and publishing', async () => {
let sk = generatePrivateKey()
let pk = getPublicKey(sk)
var resolve1: (value: boolean) => void
var resolve2: (value: boolean) => void
let sub = relay.sub([
{
kinds: [27572],
authors: [pk]
}
])
sub.on('event', event => {
expect(event).toHaveProperty('pubkey', pk)
expect(event).toHaveProperty('kind', 27572)
expect(event).toHaveProperty('content', 'nostr-tools test suite')
resolve1(true)
})
sub.on('event', event => {
expect(event).toHaveProperty('pubkey', pk)
expect(event).toHaveProperty('kind', 27572)
expect(event).toHaveProperty('content', 'nostr-tools test suite')
resolve2(true)
})
let event = finishEvent({
kind: 27572,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'nostr-tools test suite'
}, sk)
relay.publish(event)
return expect(
Promise.all([
new Promise(resolve => {
resolve1 = resolve
}),
new Promise(resolve => {
resolve2 = resolve
})
])
).resolves.toEqual([true, true])
})

401
relay.ts Normal file
View File

@@ -0,0 +1,401 @@
/* global WebSocket */
import {verifySignature, validateEvent, type Event} from './event.ts'
import {matchFilters, type Filter} from './filter.ts'
import {getHex64, getSubscriptionId} from './fakejson.ts'
import { MessageQueue } from './utils.ts'
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<K extends number> = {
event: (event: Event<K>) => void | Promise<void>
count: (payload: CountPayload) => void | Promise<void>
eose: () => void | Promise<void>
}
export type Relay = {
url: string
status: number
connect: () => Promise<void>
close: () => void
sub: <K extends number = number>(filters: Filter<K>[], opts?: SubscriptionOptions) => Sub<K>
list: <K extends number = number>(filters: Filter<K>[], opts?: SubscriptionOptions) => Promise<Event<K>[]>
get: <K extends number = number>(filter: Filter<K>, opts?: SubscriptionOptions) => Promise<Event<K> | null>
count: (
filters: Filter[],
opts?: SubscriptionOptions
) => Promise<CountPayload | null>
publish: (event: Event<number>) => Pub
auth: (event: Event<number>) => 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' | 'failed', cb: any) => void
off: (type: 'ok' | 'failed', cb: any) => void
}
export type Sub<K extends number = number> = {
sub: <K extends number = number>(filters: Filter<K>[], opts: SubscriptionOptions) => Sub<K>
unsub: () => void
on: <T extends keyof SubEvent<K>, U extends SubEvent<K>[T]>(
event: T,
listener: U
) => void
off: <T extends keyof SubEvent<K>, U extends SubEvent<K>[T]>(
event: T,
listener: U
) => void
}
export type SubscriptionOptions = {
id?: string
verb?: 'REQ' | 'COUNT'
skipVerification?: boolean
alreadyHaveEvent?: null | ((id: string, relay: string) => boolean)
}
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 openSubs: {[id: string]: {filters: Filter[]} & SubscriptionOptions} = {}
var listeners = newListeners()
var subListeners: {
[subid: string]: {[TK in keyof SubEvent<any>]: SubEvent<any>[TK][]}
} = {}
var pubListeners: {
[eventid: string]: {
ok: Array<() => void>
seen: Array<() => void>
failed: Array<(reason: string) => void>
}
} = {}
var connectionPromise: Promise<void> | undefined
async function connectRelay(): Promise<void> {
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())
resolve()
}
ws.onerror = () => {
connectionPromise = undefined
listeners.error.forEach(cb => cb())
reject()
}
ws.onclose = async () => {
connectionPromise = undefined
listeners.disconnect.forEach(cb => cb())
}
let incomingMessageQueue: MessageQueue = new MessageQueue()
let handleNextInterval: any
ws.onmessage = e => {
incomingMessageQueue.enqueue(e.data)
if (!handleNextInterval) {
handleNextInterval = setInterval(handleNext, 0)
}
}
function handleNext() {
if (incomingMessageQueue.size === 0) {
clearInterval(handleNextInterval)
handleNextInterval = null
return
}
var json = incomingMessageQueue.dequeue()
if (!json) return
let subid = getSubscriptionId(json)
if (subid) {
let so = openSubs[subid]
if (
so &&
so.alreadyHaveEvent &&
so.alreadyHaveEvent(getHex64(json, 'id'), url)
) {
return
}
}
try {
let data = JSON.parse(json)
// we won't do any checks against the data since all failures (i.e. invalid messages from relays)
// will naturally be caught by the encompassing try..catch block
switch (data[0]) {
case 'EVENT': {
let id = data[1]
let event = data[2]
if (
validateEvent(event) &&
openSubs[id] &&
(openSubs[id].skipVerification || verifySignature(event)) &&
matchFilters(openSubs[id].filters, event)
) {
openSubs[id]
;(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]
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 (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 (connected()) return // ws already open
await connectRelay()
}
async function trySend(params: [string, ...any]) {
let msg = JSON.stringify(params)
if (!connected()) {
await new Promise(resolve => setTimeout(resolve, 1000))
if (!connected()) {
return
}
}
try {
ws.send(msg)
} catch (err) {
console.log(err)
}
}
const sub = <K extends number = number>(
filters: Filter<K>[],
{
verb = 'REQ',
skipVerification = false,
alreadyHaveEvent = null,
id = Math.random().toString().slice(2)
}: SubscriptionOptions = {}
): Sub<K> => {
let subid = id
openSubs[subid] = {
id: subid,
filters,
skipVerification,
alreadyHaveEvent
}
trySend([verb, subid, ...filters])
return {
sub: (newFilters, newOpts = {}) =>
sub(newFilters || filters, {
skipVerification: newOpts.skipVerification || skipVerification,
alreadyHaveEvent: newOpts.alreadyHaveEvent || alreadyHaveEvent,
id: subid
}),
unsub: () => {
delete openSubs[subid]
delete subListeners[subid]
trySend(['CLOSE', subid])
},
on: (type, cb) => {
subListeners[subid] = subListeners[subid] || {
event: [],
count: [],
eose: []
}
subListeners[subid][type].push(cb)
},
off: (type, cb): void => {
let listeners = subListeners[subid]
let idx = listeners[type].indexOf(cb)
if (idx >= 0) listeners[type].splice(idx, 1)
}
}
}
function _publishEvent(event: Event<number>, 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: <T extends keyof RelayEvent, U extends RelayEvent[T]>(
type: T,
cb: U
): void => {
listeners[type].push(cb)
if (type === 'connect' && ws?.readyState === 1) {
// i would love to know why we need this
;(cb as () => 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)
},
list: (filters, opts?: SubscriptionOptions) =>
new Promise(resolve => {
let s = sub(filters, opts)
let events: Event<any>[] = []
let timeout = setTimeout(() => {
s.unsub()
resolve(events)
}, listTimeout)
s.on('eose', () => {
s.unsub()
clearTimeout(timeout)
resolve(events)
})
s.on('event', (event) => {
events.push(event)
})
}),
get: (filter, opts?: SubscriptionOptions) =>
new Promise(resolve => {
let s = sub([filter], opts)
let timeout = setTimeout(() => {
s.unsub()
resolve(null)
}, getTimeout)
s.on('event', (event) => {
s.unsub()
clearTimeout(timeout)
resolve(event)
})
}),
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)
})
}),
publish(event): Pub {
return _publishEvent(event, 'EVENT')
},
auth(event): Pub {
return _publishEvent(event, 'AUTH')
},
connect,
close(): void {
listeners = newListeners()
subListeners = {}
pubListeners = {}
if (ws.readyState === WebSocket.OPEN) {
ws?.close()
}
},
get status() {
return ws?.readyState ?? 3
}
}
}

17
test-helpers.ts Normal file
View File

@@ -0,0 +1,17 @@
import type {Event} from './event.ts'
type EventParams<K extends number> = Partial<Event<K>>
/** Build an event for testing purposes. */
export function buildEvent<K extends number = 1>(params: EventParams<K>): Event<K> {
return {
id: '',
kind: 1 as K,
pubkey: '',
created_at: 0,
content: '',
tags: [],
sig: '',
...params
}
}

16
tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"module": "esnext",
"target": "esnext",
"lib": ["dom", "dom.iterable", "esnext"],
"declaration": true,
"strict": true,
"moduleResolution": "node",
"skipLibCheck": true,
"esModuleInterop": true,
"emitDeclarationOnly": true,
"outDir": "lib",
"rootDir": ".",
"allowImportingTsExtensions": true
}
}

239
utils.test.ts Normal file
View File

@@ -0,0 +1,239 @@
import {buildEvent} from './test-helpers.ts'
import {
MessageQueue,
insertEventIntoAscendingList,
insertEventIntoDescendingList,
} from './utils.ts'
import type {Event} from './event.ts'
describe('inserting into a desc sorted list of events', () => {
test('insert into an empty list', async () => {
const list0: Event[] = []
expect(
insertEventIntoDescendingList(list0, buildEvent({id: 'abc', created_at: 10}))
).toHaveLength(1)
})
test('insert in the beginning of a list', async () => {
const list0 = [buildEvent({created_at: 20}), buildEvent({created_at: 10})]
const list1 = insertEventIntoDescendingList(list0, buildEvent({
id: 'abc',
created_at: 30
}))
expect(list1).toHaveLength(3)
expect(list1[0].id).toBe('abc')
})
test('insert in the beginning of a list with same created_at', async () => {
const list0 = [
buildEvent({created_at: 30}),
buildEvent({created_at: 20}),
buildEvent({created_at: 10}),
]
const list1 = insertEventIntoDescendingList(list0, buildEvent({
id: 'abc',
created_at: 30
}))
expect(list1).toHaveLength(4)
expect(list1[0].id).toBe('abc')
})
test('insert in the middle of a list', async () => {
const list0 = [
buildEvent({created_at: 30}),
buildEvent({created_at: 20}),
buildEvent({created_at: 10}),
buildEvent({created_at: 1}),
]
const list1 = insertEventIntoDescendingList(list0, buildEvent({
id: 'abc',
created_at: 15
}))
expect(list1).toHaveLength(5)
expect(list1[2].id).toBe('abc')
})
test('insert in the end of a list', async () => {
const list0 = [
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 10}),
]
const list1 = insertEventIntoDescendingList(list0, buildEvent({
id: 'abc',
created_at: 5
}))
expect(list1).toHaveLength(6)
expect(list1.slice(-1)[0].id).toBe('abc')
})
test('insert in the last-to-end of a list with same created_at', async () => {
const list0: Event[] = [
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 10}),
]
const list1 = insertEventIntoDescendingList(list0, buildEvent({
id: 'abc',
created_at: 10
}))
expect(list1).toHaveLength(6)
expect(list1.slice(-2)[0].id).toBe('abc')
})
test('do not insert duplicates', async () => {
const list0 = [
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 10, id: 'abc'}),
]
const list1 = insertEventIntoDescendingList(list0, buildEvent({
id: 'abc',
created_at: 10
}))
expect(list1).toHaveLength(3)
})
})
describe('inserting into a asc sorted list of events', () => {
test('insert into an empty list', async () => {
const list0: Event[] = []
expect(
insertEventIntoAscendingList(list0, buildEvent({id: 'abc', created_at: 10}))
).toHaveLength(1)
})
test('insert in the beginning of a list', async () => {
const list0 = [buildEvent({created_at: 10}), buildEvent({created_at: 20})]
const list1 = insertEventIntoAscendingList(list0, buildEvent({
id: 'abc',
created_at: 1
}))
expect(list1).toHaveLength(3)
expect(list1[0].id).toBe('abc')
})
test('insert in the beginning of a list with same created_at', async () => {
const list0 = [
buildEvent({created_at: 10}),
buildEvent({created_at: 20}),
buildEvent({created_at: 30}),
]
const list1 = insertEventIntoAscendingList(list0, buildEvent({
id: 'abc',
created_at: 10
}))
expect(list1).toHaveLength(4)
expect(list1[0].id).toBe('abc')
})
test('insert in the middle of a list', async () => {
const list0 = [
buildEvent({created_at: 10}),
buildEvent({created_at: 20}),
buildEvent({created_at: 30}),
buildEvent({created_at: 40}),
]
const list1 = insertEventIntoAscendingList(list0, buildEvent({
id: 'abc',
created_at: 25
}))
expect(list1).toHaveLength(5)
expect(list1[2].id).toBe('abc')
})
test('insert in the end of a list', async () => {
const list0 = [
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 40}),
]
const list1 = insertEventIntoAscendingList(list0, buildEvent({
id: 'abc',
created_at: 50
}))
expect(list1).toHaveLength(6)
expect(list1.slice(-1)[0].id).toBe('abc')
})
test('insert in the last-to-end of a list with same created_at', async () => {
const list0 = [
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 30}),
]
const list1 = insertEventIntoAscendingList(list0, buildEvent({
id: 'abc',
created_at: 30
}))
expect(list1).toHaveLength(6)
expect(list1.slice(-2)[0].id).toBe('abc')
})
test('do not insert duplicates', async () => {
const list0 = [
buildEvent({created_at: 20}),
buildEvent({created_at: 20}),
buildEvent({created_at: 30, id: 'abc'}),
]
const list1 = insertEventIntoAscendingList(list0, buildEvent({
id: 'abc',
created_at: 30
}))
expect(list1).toHaveLength(3)
})
})
describe('enque a message into MessageQueue', () => {
test('enque into an empty queue', () => {
const queue = new MessageQueue()
queue.enqueue('node1')
expect(queue.first!.value).toBe('node1')
})
test('enque into a non-empty queue', () => {
const queue = new MessageQueue()
queue.enqueue('node1')
queue.enqueue('node3')
queue.enqueue('node2')
expect(queue.first!.value).toBe('node1')
expect(queue.last!.value).toBe('node2')
expect(queue.size).toBe(3)
})
test('dequeue from an empty queue', () => {
const queue = new MessageQueue()
const item1 = queue.dequeue()
expect(item1).toBe(null)
expect(queue.size).toBe(0)
})
test('dequeue from a non-empty queue', () => {
const queue = new MessageQueue()
queue.enqueue('node1')
queue.enqueue('node3')
queue.enqueue('node2')
const item1 = queue.dequeue()
expect(item1).toBe('node1')
const item2 = queue.dequeue()
expect(item2).toBe('node3')
})
test('dequeue more than in queue', () => {
const queue = new MessageQueue()
queue.enqueue('node1')
queue.enqueue('node3')
const item1 = queue.dequeue()
expect(item1).toBe('node1')
const item2 = queue.dequeue()
expect(item2).toBe('node3')
expect(queue.size).toBe(0)
const item3 = queue.dequeue()
expect(item3).toBe(null)
})
})

187
utils.ts Normal file
View File

@@ -0,0 +1,187 @@
import type {Event} from './event.ts'
export const utf8Decoder = new TextDecoder('utf-8')
export const utf8Encoder = new TextEncoder()
export function normalizeURL(url: string): string {
let p = new URL(url)
p.pathname = p.pathname.replace(/\/+/g, '/')
if (p.pathname.endsWith('/')) p.pathname = p.pathname.slice(0, -1)
if (
(p.port === '80' && p.protocol === 'ws:') ||
(p.port === '443' && p.protocol === 'wss:')
)
p.port = ''
p.searchParams.sort()
p.hash = ''
return p.toString()
}
//
// fast insert-into-sorted-array functions adapted from https://github.com/terrymorse58/fast-sorted-array
//
export function insertEventIntoDescendingList(
sortedArray: Event<number>[],
event: Event<number>
) {
let start = 0
let end = sortedArray.length - 1
let midPoint
let position = start
if (end < 0) {
position = 0
} else if (event.created_at < sortedArray[end].created_at) {
position = end + 1
} else if (event.created_at >= sortedArray[start].created_at) {
position = start
} else
while (true) {
if (end <= start + 1) {
position = end
break
}
midPoint = Math.floor(start + (end - start) / 2)
if (sortedArray[midPoint].created_at > event.created_at) {
start = midPoint
} else if (sortedArray[midPoint].created_at < event.created_at) {
end = midPoint
} else {
// aMidPoint === num
position = midPoint
break
}
}
// insert when num is NOT already in (no duplicates)
if (sortedArray[position]?.id !== event.id) {
return [
...sortedArray.slice(0, position),
event,
...sortedArray.slice(position)
]
}
return sortedArray
}
export function insertEventIntoAscendingList(
sortedArray: Event<number>[],
event: Event<number>
) {
let start = 0
let end = sortedArray.length - 1
let midPoint
let position = start
if (end < 0) {
position = 0
} else if (event.created_at > sortedArray[end].created_at) {
position = end + 1
} else if (event.created_at <= sortedArray[start].created_at) {
position = start
} else
while (true) {
if (end <= start + 1) {
position = end
break
}
midPoint = Math.floor(start + (end - start) / 2)
if (sortedArray[midPoint].created_at < event.created_at) {
start = midPoint
} else if (sortedArray[midPoint].created_at > event.created_at) {
end = midPoint
} else {
// aMidPoint === num
position = midPoint
break
}
}
// insert when num is NOT already in (no duplicates)
if (sortedArray[position]?.id !== event.id) {
return [
...sortedArray.slice(0, position),
event,
...sortedArray.slice(position)
]
}
return sortedArray
}
export class MessageNode {
private _value: string
private _next: MessageNode | null
public get value(): string {
return this._value
}
public set value(message: string) {
this._value = message
}
public get next(): MessageNode | null {
return this._next
}
public set next(node: MessageNode | null) {
this._next = node
}
constructor(message: string) {
this._value = message
this._next = null
}
}
export class MessageQueue {
private _first: MessageNode | null
private _last: MessageNode | null
public get first(): MessageNode | null {
return this._first
}
public set first(messageNode: MessageNode | null) {
this._first = messageNode
}
public get last(): MessageNode | null {
return this._last
}
public set last(messageNode: MessageNode | null) {
this._last = messageNode
}
private _size: number
public get size(): number {
return this._size
}
public set size(v: number) {
this._size = v
}
constructor() {
this._first = null
this._last = null
this._size = 0
}
enqueue(message: string): boolean {
const newNode = new MessageNode(message)
if (this._size === 0 || !this._last) {
this._first = newNode
this._last = newNode
} else {
this._last.next = newNode
this._last = newNode
}
this._size++
return true
}
dequeue(): string | null {
if (this._size === 0 || !this._first) return null
let prev = this._first
this._first = prev.next
prev.next = null
this._size--
return prev.value
}
}

4100
yarn.lock Normal file

File diff suppressed because it is too large Load Diff