Compare commits

...

325 Commits

Author SHA1 Message Date
fiatjaf
1b31a27d89 ensure types are emitted when publishing. 2023-12-20 10:51:41 -03:00
fiatjaf
0cc3c02d84 fix fix yield. 2023-12-20 10:49:08 -03:00
Shusui MOYATANI
8625d45152 fix yield 2023-12-20 09:27:49 -03:00
fiatjaf
8f03116687 tweak readme. 2023-12-19 14:21:04 -03:00
fiatjaf
e6d1808fda update readme to mention fragment importing and nostr-wasm. 2023-12-19 14:14:40 -03:00
fiatjaf
9648de3470 update build process and list of exports. 2023-12-19 14:01:28 -03:00
fiatjaf
fe87529646 change tests and nips to use the new api. 2023-12-19 13:58:37 -03:00
fiatjaf
1908e1ee0d revamp core api + option to use nostr-wasm instead of noble-curves. 2023-12-19 12:20:42 -03:00
fiatjaf
2571db9afc fix validateEvent() signature. 2023-12-19 10:36:54 -03:00
fiatjaf
f77b9eab10 remove auto-publishing to npm. 2023-12-19 10:33:06 -03:00
fiatjaf
71b412657f .subscribe() is not async. 2023-12-19 10:22:29 -03:00
fiatjaf
8840c4d8e2 final adjustments and now even the flaky tests that depend on others's relay should pass most of the time. 2023-12-19 10:01:52 -03:00
fiatjaf
804403f574 change the way eose and connection timeouts work. 2023-12-18 17:11:16 -03:00
fiatjaf
965ebdb6d1 higher time limit for tests on github. 2023-12-18 13:15:19 -03:00
fiatjaf
c54fd95b3e decrease default eoseTimeout to 3400ms. 2023-12-18 10:18:34 -03:00
fiatjaf
7a6c0754ad fix github actions again and put a badge in the readme. 2023-12-18 09:53:44 -03:00
fiatjaf
9e4911160a make pool.subscribe_ methods return synchronously. 2023-12-18 09:53:06 -03:00
fiatjaf
73c6630cf7 fix github actions test. 2023-12-17 22:49:58 -03:00
fiatjaf
88703e9ea2 update readme with new api. 2023-12-17 22:46:35 -03:00
fiatjaf
07d208308f remove broken useless tests. 2023-12-17 22:41:22 -03:00
fiatjaf
f56f2ae709 pool tests and pool.ts tweaks. 2023-12-17 22:19:28 -03:00
fiatjaf
a0cb2eecae get rid of RelayTrackingPool, merge it into SimplePool. 2023-12-17 19:15:27 -03:00
fiatjaf
2a7fd83be8 rewrite binarySearch so it doesn't have to compare values of the same type. 2023-12-17 18:13:09 -03:00
fiatjaf
1ebe098805 binarySearch and improve insertEventInto___List() to use that and .splice() 2023-12-17 18:06:58 -03:00
fiatjaf
3bfb50e267 rewrite pool.ts to be much simpler. 2023-12-17 11:19:50 -03:00
fiatjaf
420a6910e9 fix Queue, tweaks on relay.ts and make relay.test.ts pass. 2023-12-17 00:27:03 -03:00
fiatjaf
7a640092d0 rewrite relay.ts to be much simpler. 2023-12-16 18:56:18 -03:00
fiatjaf
3d541e537e move to bun and bun:test and remove jest. 2023-12-16 14:53:32 -03:00
fiatjaf
1357642575 adjust packages exported. 2023-12-16 13:08:37 -03:00
fiatjaf
d16f3f77c3 prettify and lint. 2023-12-16 12:39:24 -03:00
fiatjaf
0108e3b605 remove nip-44 stuff. 2023-12-16 12:39:07 -03:00
fiatjaf
2ac69278ce simplify nip-42. 2023-12-16 11:21:49 -03:00
fiatjaf
bf31f2eba3 fix nip-47 by removing some useless time checks. 2023-12-16 11:08:51 -03:00
fiatjaf
39cfc5c09e cleanup nip-11. 2023-12-16 11:00:46 -03:00
futpib
3d767beeb9 NIP-06: Support multiple account private keys derived from seed words (#219)
Co-authored-by: fiatjaf_ <fiatjaf@gmail.com>
2023-12-16 10:15:37 -03:00
Alex Gleason
36e0de2a68 Add NIP-30 module for custom emojis 2023-12-16 10:13:40 -03:00
Giacomo Gagliano
9cd4f16e45 nip11 - Types, requestRelayInfos() and tests 2023-12-16 10:13:21 -03:00
fiatjaf
6a07e7c1cc remove the kind type parameter from events and filters. 2023-12-16 10:10:37 -03:00
fiatjaf
1939c46eaa turn kinds enum into simple constants in kinds.ts, bring more kind numbers from the nips readme. 2023-12-16 09:27:59 -03:00
fiatjaf
93538d2373 update dependencies. 2023-12-16 08:51:43 -03:00
fiatjaf
19b3faea17 fix nip05 test. 2023-12-16 08:51:33 -03:00
fiatjaf
867aa11d12 remove all the NIP-26 stuff. 2023-12-13 15:24:57 -03:00
fiatjaf
4fcf925387 nip04: augment tests with cross-compatibility vectors. 2023-12-02 13:13:16 -03:00
Yijia Su
40c5337ef0 Update @noble/curves to 1.2.0 2023-11-28 15:50:54 -03:00
fiatjaf
350d8ec3b6 remove nip06 from main export bundle. 2023-11-13 17:35:32 -03:00
Josh Remaley
c5f3c8052e update to test for body payload and payload hash 2023-11-13 14:30:42 -03:00
Josh Remaley
dc04d1eb85 update to support body payload and hash 2023-11-13 14:30:42 -03:00
William Connatser
a2a15567b7 clean up test with a minor refactor to delete the ts-ignore 2023-10-24 08:41:40 -03:00
fiatjaf
318e3f8c88 we don't have to bump to 2.0.0 since this will not break backwards-compatibility. 2023-10-15 17:58:42 -03:00
fiatjaf
894ffff1f0 prefix exported modules with ./ (esbuild requires this apparently). 2023-10-14 07:57:30 -03:00
franzap
ce11a5fc89 Organize build, allow one entrypoint per file (#305) 2023-10-01 18:20:53 -03:00
Paul Miller
5e85bbc2ed Fix nip44 vectors (#308)
* Fix nip44 vectors

* Update vectors

* Update vectors
2023-09-30 18:46:45 -03:00
Paul Miller
eb0a9093f2 Implement NIP-44: secure versioned replacement for NIP4 (#221) 2023-09-29 20:43:48 -03:00
Sherry
c73268c4e2 Add kind to nevent decode and encode (#304) 2023-09-26 12:20:17 -03:00
fiatjaf
6874f58c0a apply prettier. 2023-09-26 12:19:01 -03:00
Sepehr Safari
e899cc32b7 edit batchedList and add mergeFilters to it 2023-09-24 20:49:45 -03:00
Sam Samskies
de72172583 add helper functions for nip-47 2023-09-19 14:43:01 -03:00
Sepehr Safari
073dcaafd6 Update README.md
add docs about batchedList.
2023-09-15 20:38:39 -03:00
Alex Gleason
8e932f0c5a Merge pull request #295 from AsaiToshiya/patch-1
Improve example for finishEvent
2023-09-10 22:59:46 -05:00
Asai Toshiya
f9a048679f Improve example for finishEvent 2023-09-11 12:53:25 +09:00
Alex Gleason
6db8b94275 nip13: add minePow function 2023-09-10 15:45:31 -03:00
fiatjaf
13bc2ad5a8 trick typescript into accepting our types. 2023-09-10 15:44:22 -03:00
fiatjaf
55f032d0a4 tag v1.15.0 2023-09-10 15:16:04 -03:00
Alex Gleason
c890e29290 nip13: use a simpler implementation 2023-09-10 15:15:33 -03:00
Alex Gleason
c18f050468 relay: sub.events async iterator 2023-09-09 19:05:21 -03:00
Alex Gleason
401b9c7864 Make TypeScript >= 5.0.0 an optional peer dependency 2023-09-03 20:56:05 -03:00
fiatjaf_
c175f6c804 Merge pull request #289 from alexgleason/verified 2023-09-03 15:47:50 -03:00
Alex Gleason
41265a19f5 event.test: tamper with things in a more evil way 2023-09-03 12:12:42 -05:00
Alex Gleason
d88761907a verifySignature: set verifiedSymbol to false on failure, DRY return values 2023-09-02 18:08:09 -05:00
Alex Gleason
8325d4351e just format 2023-09-02 17:40:00 -05:00
Alex Gleason
62bf592d72 finishEvent: return a VerifiedEvent 2023-09-02 17:39:35 -05:00
Alex Gleason
54f3bedf38 verifySignature: return false if the id is invalid 2023-09-02 17:39:28 -05:00
Alex Gleason
34e0ad8c41 Add a symbol to verified events 2023-09-02 18:04:10 -03:00
Egge
e9eac28bab Added eoseSubTimeout to pool's SubscriptionOptions (#284)
* added timeout sub option

* made eoseSubTimeout optional
2023-09-01 07:50:12 -03:00
fiatjaf_
85035d61f2 Merge pull request #287 from alexgleason/prettier
Fix tests, format everything with prettier and enforce prettier+eslint in the CI
2023-09-01 07:48:06 -03:00
Alex Gleason
cf46560619 ci: ensure just is available to the runner 2023-08-31 13:52:56 -05:00
Alex Gleason
e7aa23cb1d README: add a note about typescript 5.0 2023-08-31 13:51:17 -05:00
Alex Gleason
5977d68ec2 nip98.test: remove outdated/failing test 2023-08-31 13:47:16 -05:00
Alex Gleason
48767d382d relay.test: increase querying timeout to 10s 2023-08-31 13:45:39 -05:00
Alex Gleason
718032022c just format 2023-08-31 13:42:15 -05:00
Alex Gleason
2a70bb18ff pool: use triple-equals 2023-08-31 13:41:40 -05:00
Alex Gleason
9effe807d1 filter: remove unused import for Kind 2023-08-31 13:41:25 -05:00
Alex Gleason
899c2bd0dc eslint: remove conflicting generator-star-spacing rule 2023-08-31 13:41:06 -05:00
Alex Gleason
918d514a25 Upgrade all eslint deps 2023-08-31 13:37:45 -05:00
Alex Gleason
48cb9046c4 Add eslint-config-prettier to solve conflicts between prettier and eslint 2023-08-31 13:27:28 -05:00
Alex Gleason
864dd28b26 justfile: improve lint/format commands 2023-08-31 13:25:30 -05:00
Alex Gleason
fa085367c9 Add eslint to just format 2023-08-31 13:22:43 -05:00
Alex Gleason
350951b88e Add eslint to just lint 2023-08-31 13:21:10 -05:00
Alex Gleason
c6133f7160 ci: run prettier on every commit 2023-08-31 13:14:16 -05:00
Alex Gleason
470512bbeb prettier: increase printWidth, enable bracketSpacing, alphabetize 2023-08-31 13:00:50 -05:00
Alex Gleason
c3acb82464 Upgrade Prettier to v3.0.3 2023-08-31 12:59:54 -05:00
Alex Gleason
fc23d05764 Merge pull request #283 from jiftechnify/fix-code-samples
Fix code samples in README
2023-08-27 12:14:32 -05:00
jiftechnify
8296ce897c fix a code sample in README
- add pool.close() usage
2023-08-28 01:54:33 +09:00
jiftechnify
3ca78c0e13 fix code samples in README 2023-08-28 01:47:35 +09:00
Alex Gleason
837a05e54d Add kinds module to classify events by kind 2023-08-26 22:26:04 -03:00
ffaex
32fd25556b added new event kind 1063
see https://github.com/nostr-protocol/nips/blob/master/94.md
2023-08-21 15:03:23 -03:00
Sepehr Safari
0925f5db81 add batchedList method to SimplePool 2023-08-21 10:44:33 -03:00
fiatjaf
bce976fecd get rid of httpmethod enum. 2023-08-16 14:07:26 -03:00
fiatjaf
45e479d7aa let it throw. 2023-08-16 13:59:31 -03:00
Jon Staab
b92407b156 nip44 updates (#278)
Co-authored-by: Jonathan Staab <shtaab@gmail.com>
2023-08-16 13:53:37 -03:00
Pierre Buyle
2431896921 fix(nip98): Add support for HEAD, PUT, CONNECT, OPTIONS, TRACE and PATCH http methods
This PR adds common HTTP methods (as listed on https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods)
2023-08-15 11:03:47 -03:00
Jonathan Staab
d13eecad4a Add support for nip44 2023-08-12 20:46:32 -03:00
Alex Gleason
df6f887d7e Event, Filter: allow any kind number
Fixes https://github.com/nbd-wtf/nostr-tools/issues/275
2023-08-12 20:13:53 -03:00
Alex Gleason
e00362e7c9 Filter: let tag queries be undefined 2023-08-12 16:30:24 -03:00
fiatjaf
9efdd16e26 fix check for undefined ws
fixes https://github.com/nbd-wtf/nostr-tools/issues/271
2023-08-11 07:09:40 -03:00
Alex Gleason
de7e128818 Merge pull request #267 from Airtune/nip98-extract-pubkey
+nip98.unpackEventFromToken +nip98.validateEvent
2023-08-08 08:48:29 -05:00
Airtune
4978c858e7 Update nip98.ts examples 2023-08-08 02:45:23 -04:00
Airtune
16c7ae2a70 +nip98.unpackEventFromToken +nip98.validateEvent 2023-08-07 22:16:23 -04:00
fiatjaf
3368e8c00e bump minor version because of the breaking change on publish()
yes, I don't understand semver
2023-07-31 23:05:36 -03:00
Airtune
e5a3ad9855 Export nip28 functions in index.ts and bump version (#265) 2023-07-31 23:04:45 -03:00
Airtune
03185c654b Create nip28.ts and nip28.test.ts (#264) 2023-07-31 08:29:45 -03:00
fiatjaf
9d690814ca turn .publish() into a normal async function returning a promise.
this simplifies the code and makes the API more intuitive.

we used to need the event emitter thing because we were subscribing to the same relay
to check if the event had been published, but that is not necessary now that we assume
an OK response will always come.

closes https://github.com/nbd-wtf/nostr-tools/issues/262
2023-07-30 18:23:05 -03:00
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
78 changed files with 5649 additions and 990 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,8 +1,9 @@
{
"root": true,
"extends": ["prettier"],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"plugins": ["@typescript-eslint", "babel"],
"parserOptions": {
"ecmaVersion": 9,
@@ -18,8 +19,6 @@
"node": true
},
"plugins": ["babel"],
"globals": {
"document": false,
"navigator": false,
@@ -36,23 +35,22 @@
"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)$"],
"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,
@@ -84,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,
@@ -103,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,
@@ -118,34 +115,23 @@
"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,

View File

@@ -1,21 +0,0 @@
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
- run: yarn --ignore-engines
- run: node build.js
- run: yarn test
- uses: JS-DevTools/npm-publish@v1
with:
token: ${{ secrets.NPM_TOKEN }}
greater-version-only: true

View File

@@ -3,15 +3,22 @@ on:
push:
branches:
- master
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v3
with:
node-version: 18
- run: yarn --ignore-engines
- run: node build.js
- run: yarn test
- uses: actions/checkout@v3
- uses: oven-sh/setup-bun@v1
- uses: extractions/setup-just@v1
- run: bun i
- run: just test
format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: oven-sh/setup-bun@v1
- uses: extractions/setup-just@v1
- run: bun i
- run: just lint

View File

@@ -1,10 +1,9 @@
semi: false
arrowParens: avoid
bracketSpacing: true
insertPragma: false
printWidth: 80
printWidth: 120
proseWrap: preserve
semi: false
singleQuote: true
trailingComma: none
trailingComma: all
useTabs: false
jsxBracketSameLine: false
bracketSpacing: false

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>

250
README.md
View File

@@ -1,89 +1,76 @@
# nostr-tools
# ![](https://img.shields.io/github/actions/workflow/status/nbd-wtf/nostr-tools/test.yml) nostr-tools
Tools for developing [Nostr](https://github.com/fiatjaf/nostr) clients.
Very lean on dependencies.
Only depends on _@scure_ and _@noble_ packages.
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).
## Installation
```bash
npm install nostr-tools # or yarn add nostr-tools
```
If using TypeScript, this package requires TypeScript >= 5.0.
## Usage
### Generating a private key and a public key
```js
import {generatePrivateKey, getPublicKey} from 'nostr-tools'
import { generateSecretKey, getPublicKey } from 'nostr-tools'
let sk = generatePrivateKey() // `sk` is a hex string
let sk = generateSecretKey() // `sk` is a hex string
let pk = getPublicKey(sk) // `pk` is a hex string
```
### Creating, signing and verifying events
```js
import {
validateEvent,
verifySignature,
signEvent,
getEventHash,
getPublicKey
} from 'nostr-tools'
import { finalizeEvent, verifyEvent } from 'nostr-tools'
let event = {
let event = finalizeEvent({
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'hello'
}
content: 'hello',
}, sk)
event.id = getEventHash(event)
event.pubkey = getPublicKey(privateKey)
event.sig = await signEvent(event, privateKey)
let ok = validateEvent(event)
let veryOk = await verifySignature(event)
let isGood = verifyEvent(event)
```
### Interacting with a relay
```js
import {
relayInit,
generatePrivateKey,
getPublicKey,
getEventHash,
signEvent
} from 'nostr-tools'
import { relayConnect, finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools'
const relay = relayInit('wss://relay.example.com')
await relay.connect()
relay.on('connect', () => {
console.log(`connected to ${relay.url}`)
})
relay.on('error', () => {
console.log(`failed to connect to ${relay.url}`)
})
const relay = await relayConnect('wss://relay.example.com')
console.log(`connected to ${relay.url}`)
// let's query for an event that exists
let sub = relay.sub([
const sub = relay.subscribe([
{
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027']
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'],
},
], {
onevent(event) {
console.log('we got the event we wanted:', event)
},
oneose() {
sub.close()
}
])
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 sk = generateSecretKey()
let pk = getPublicKey(sk)
let sub = relay.sub([
{
kinds: [1],
authors: [pk]
}
authors: [pk],
},
])
sub.on('event', event => {
@@ -92,26 +79,21 @@ sub.on('event', event => {
let event = {
kind: 1,
pubkey: pk,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'hello world'
content: 'hello world',
}
event.id = getEventHash(event)
event.sig = await signEvent(event, sk)
let pub = relay.publish(event)
pub.on('ok', () => {
console.log(`{relay.url} has accepted our event`)
})
pub.on('seen', () => {
console.log(`we saw the event on {relay.url}`)
})
pub.on('failed', reason => {
console.log(`failed to publish to {relay.url}: ${reason}`)
// this assigns the pubkey, calculates the event id and signs the event in a single step
const signedEvent = finalizeEvent(event, sk)
await relay.publish(signedEvent)
let events = await relay.list([{ kinds: [0, 1] }])
let event = await relay.get({
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
})
await relay.close()
relay.close()
```
To use this on Node.js you first must install `websocket-polyfill` and import it:
@@ -120,10 +102,66 @@ To use this on Node.js you first must install `websocket-polyfill` and import it
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 h = pool.subscribeMany(
[...relays, 'wss://relay.example3.com'],
[
{
authors: ['32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
},
],
{
onevent(event) {
// this will only be called once the first time the event is received
// ...
},
oneose() {
h.close()
}
}
)
await Promise.any(pool.publish(relays, newEvent))
console.log('published to at least one relay!')
let events = await pool.querySync(relays, [{ kinds: [0, 1] }])
let event = await pool.get(relays, {
ids: ['44e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'],
})
```
### 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'
import { nip05 } from 'nostr-tools'
let profile = await nip05.queryProfile('jb55.com')
console.log(profile.pubkey)
@@ -132,7 +170,7 @@ 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:
To use this on Node.js < v18, you first must install `node-fetch@2` and call something like this:
```js
nip05.useFetchImplementation(require('node-fetch'))
@@ -141,78 +179,72 @@ nip05.useFetchImplementation(require('node-fetch'))
### Encoding and decoding NIP-19 codes
```js
import {nip19, generatePrivateKey, getPublicKey} from 'nostr-tools'
import { nip19, generateSecretKey, getPublicKey } from 'nostr-tools'
let sk = generatePrivateKey()
let sk = generateSecretKey()
let nsec = nip19.nsecEncode(sk)
let {type, data} = nip19.decode(nsec)
let { type, data } = nip19.decode(nsec)
assert(type === 'nsec')
assert(data === sk)
let pk = getPublicKey(generatePrivateKey())
let pk = getPublicKey(generateSecretKey())
let npub = nip19.npubEncode(pk)
let {type, data} = nip19.decode(npub)
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)
let pk = getPublicKey(generateSecretKey())
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
## Import modes
### Using just the packages you want
Importing the entirety of `nostr-tools` may bloat your build, so you should probably import individual packages instead:
```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, 'hello')
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.tags.find(([k, v]) => k === 'p' && && v && v !== '')[1]
pk1 === sender
let plaintext = await nip04.decrypt(sk2, pk1, event.content)
})
import { generateSecretKey, finalizeEvent, verifyEvent } from 'nostr-tools/pure'
import { matchFilter } from 'nostr-tools/filter'
import { decode, nprofileEncode, neventEncode, npubEncode } from 'nostr-tools/nip19'
// and so on and so forth
```
Please consult the tests or [the source code](https://github.com/fiatjaf/nostr-tools) for more information that isn't available here.
### Using it with `nostr-wasm`
[`nostr-wasm`](https://github.com/fiatjaf/nostr-wasm) is a thin wrapper over [libsecp256k1](https://github.com/bitcoin-core/secp256k1) compiled to WASM just for hashing, signing and verifying Nostr events.
```js
import { setNostrWasm, generateSecretKey, finalizeEvent, verifyEvent } from 'nostr-tools/wasm'
import { initNostrWasm } from 'nostr-wasm'
// make sure this promise resolves before your app starts calling finalizeEvent or verifyEvent
initNostrWasm().then(setNostrWasm)
// or use 'nostr-wasm/gzipped' or even 'nostr-wasm/headless',
// see https://www.npmjs.com/package/nostr-wasm for options
```
This may be faster than the pure-JS [noble libraries](https://paulmillr.com/noble/) used by default and in `nostr-tools/pure`.
### Using from the browser (if you don't want to use a bundler)
```html
<script src="https://unpkg.com/nostr-tools/lib/nostr.bundle.js"></script>
<script>
window.NostrTools.generatePrivateKey('...') // and so on
window.NostrTools.generateSecretKey('...') // and so on
</script>
```
## Plumbing
To develop `nostr-tools`, install [`just`](https://just.systems/) and run `just -l` to see commands available.
## 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,41 +1,59 @@
#!/usr/bin/env node
const fs = require('node:fs')
const esbuild = require('esbuild')
const { join } = require('path')
const entryPoints = fs
.readdirSync(process.cwd())
.filter(
file =>
file.endsWith('.ts') &&
file !== 'core.ts' &&
file !== 'test-helpers.ts' &&
file !== 'helpers.ts' &&
!file.endsWith('.test.ts') &&
fs.statSync(join(process.cwd(), file)).isFile(),
)
let common = {
entryPoints: ['index.ts'],
entryPoints,
bundle: true,
sourcemap: 'external'
sourcemap: 'external',
}
esbuild
.build({
...common,
outfile: 'lib/nostr.esm.js',
outdir: 'lib/esm',
format: 'esm',
packages: 'external'
packages: 'external',
})
.then(() => {
const packageJson = JSON.stringify({ type: 'module' })
fs.writeFileSync(`${__dirname}/lib/esm/package.json`, packageJson, 'utf8')
console.log('esm build success.')
})
.then(() => console.log('esm build success.'))
esbuild
.build({
...common,
outfile: 'lib/nostr.cjs.js',
outdir: 'lib/cjs',
format: 'cjs',
packages: 'external'
packages: 'external',
})
.then(() => console.log('cjs build success.'))
esbuild
.build({
...common,
entryPoints: ['index.ts'],
outfile: 'lib/nostr.bundle.js',
format: 'iife',
globalName: 'NostrTools',
define: {
window: 'self',
global: 'self',
process: '{"env": {}}'
}
process: '{"env": {}}',
},
})
.then(() => console.log('standalone build success.'))

BIN
bun.lockb Executable file

Binary file not shown.

293
core.test.ts Normal file
View File

@@ -0,0 +1,293 @@
import { describe, test, expect } from 'bun:test'
import {
finalizeEvent,
serializeEvent,
getEventHash,
validateEvent,
verifyEvent,
verifiedSymbol,
getPublicKey,
generateSecretKey,
} from './pure.ts'
import { ShortTextNote } from './kinds.ts'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
test('private key generation', () => {
expect(bytesToHex(generateSecretKey())).toMatch(/[a-f0-9]{64}/)
})
test('public key generation', () => {
expect(getPublicKey(generateSecretKey())).toMatch(/[a-f0-9]{64}/)
})
test('public key from private key deterministic', () => {
let sk = generateSecretKey()
let pk = getPublicKey(sk)
for (let i = 0; i < 5; i++) {
expect(getPublicKey(sk)).toEqual(pk)
}
})
describe('finalizeEvent', () => {
test('should create a signed event from a template', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const template = {
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
}
const event = finalizeEvent(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', () => {
test('should serialize a valid event object', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
pubkey: publicKey,
created_at: 1617932115,
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
}
const serializedEvent = serializeEvent(unsignedEvent)
expect(serializedEvent).toEqual(
JSON.stringify([
0,
publicKey,
unsignedEvent.created_at,
unsignedEvent.kind,
unsignedEvent.tags,
unsignedEvent.content,
]),
)
})
test('should throw an error for an invalid event object', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const invalidEvent = {
kind: ShortTextNote,
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', () => {
test('should return the correct event hash', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
kind: ShortTextNote,
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', () => {
test('should return true for a valid event object', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const unsignedEvent = {
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
pubkey: publicKey,
}
const isValid = validateEvent(unsignedEvent)
expect(isValid).toEqual(true)
})
test('should return false for a non object event', () => {
const nonObjectEvent = ''
const isValid = validateEvent(nonObjectEvent)
expect(isValid).toEqual(false)
})
test('should return false for an event object with missing properties', () => {
const invalidEvent = {
kind: ShortTextNote,
tags: [],
created_at: 1617932115, // missing content and pubkey
}
const isValid = validateEvent(invalidEvent)
expect(isValid).toEqual(false)
})
test('should return false for an empty object', () => {
const emptyObj = {}
const isValid = validateEvent(emptyObj)
expect(isValid).toEqual(false)
})
test('should return false for an object with invalid properties', () => {
const privateKey = hexToBytes('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)
})
test('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)
})
test('should return false for an object with invalid tags', () => {
const privateKey = hexToBytes('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('verifyEvent', () => {
test('should return true for a valid event signature', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const event = finalizeEvent(
{
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
},
privateKey,
)
const isValid = verifyEvent(event)
expect(isValid).toEqual(true)
})
test('should return false for an invalid event signature', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const { [verifiedSymbol]: _, ...event } = finalizeEvent(
{
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
},
privateKey,
)
// tamper with the signature
event.sig = event.sig.replace(/^.{3}/g, '666')
const isValid = verifyEvent(event)
expect(isValid).toEqual(false)
})
test('should return false when verifying an event with a different private key', () => {
const privateKey1 = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const privateKey2 = hexToBytes('5b4a34f4e4b23c63ad55a35e3f84a3b53d96dbf266edf521a8358f71d19cbf67')
const publicKey2 = getPublicKey(privateKey2)
const { [verifiedSymbol]: _, ...event } = finalizeEvent(
{
kind: ShortTextNote,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
},
privateKey1,
)
// verify with different private key
const isValid = verifyEvent({
...event,
pubkey: publicKey2,
})
expect(isValid).toEqual(false)
})
test('should return false for an invalid event id', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const { [verifiedSymbol]: _, ...event } = finalizeEvent(
{
kind: 1,
tags: [],
content: 'Hello, world!',
created_at: 1617932115,
},
privateKey,
)
// tamper with the id
event.id = event.id.replace(/^.{3}/g, '666')
const isValid = verifyEvent(event)
expect(isValid).toEqual(false)
})
})

50
core.ts Normal file
View File

@@ -0,0 +1,50 @@
export interface Nostr {
generateSecretKey(): Uint8Array
getPublicKey(secretKey: Uint8Array): string
finalizeEvent(event: EventTemplate, secretKey: Uint8Array): VerifiedEvent
verifyEvent(event: Event): event is VerifiedEvent
}
/** Designates a verified event signature. */
export const verifiedSymbol = Symbol('verified')
export interface Event {
kind: number
tags: string[][]
content: string
created_at: number
pubkey: string
id: string
sig: string
[verifiedSymbol]?: boolean
}
export type EventTemplate = Pick<Event, 'kind' | 'tags' | 'content' | 'created_at'>
export type UnsignedEvent = Pick<Event, 'kind' | 'tags' | 'content' | 'created_at' | 'pubkey'>
/** An event whose signature has been verified. */
export interface VerifiedEvent extends Event {
[verifiedSymbol]: true
}
const isRecord = (obj: unknown): obj is Record<string, unknown> => obj instanceof Object
export function validateEvent<T>(event: T): event is T & UnsignedEvent {
if (!isRecord(event)) return false
if (typeof event.kind !== 'number') return false
if (typeof event.content !== 'string') return false
if (typeof event.created_at !== 'number') return false
if (typeof event.pubkey !== 'string') return false
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
}

View File

@@ -1,49 +0,0 @@
/* eslint-env jest */
const {
validateEvent,
verifySignature,
signEvent,
getEventHash,
getPublicKey
} = require('./lib/nostr.cjs')
const event = {
id: 'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027',
kind: 1,
pubkey: '22a12a128a3be27cd7fb250cbe796e692896398dc1440ae3fa567812c8107c1c',
created_at: 1670869179,
content:
'NOSTR "WINE-ACCOUNT" WITH HARVEST DATE STAMPED\n\n\n"The older the wine, the greater its reputation"\n\n\n22a12a128a3be27cd7fb250cbe796e692896398dc1440ae3fa567812c8107c1c\n\n\nNWA 2022-12-12\nAA',
tags: [['client', 'astral']],
sig: 'f110e4fdf67835fb07abc72469933c40bdc7334615610cade9554bf00945a1cebf84f8d079ec325d26fefd76fe51cb589bdbe208ac9cdbd63351ddad24a57559'
}
const unsigned = {
created_at: 1671217411,
kind: 0,
tags: [],
content:
'{"name":"fiatjaf","about":"buy my merch at fiatjaf store","picture":"https://fiatjaf.com/static/favicon.jpg","nip05":"_@fiatjaf.com"}'
}
const privateKey =
'5c6c25b7ef18d8633e97512159954e1aa22809c6b763e94b9f91071836d00217'
test('validate event', () => {
expect(validateEvent(event)).toBeTruthy()
})
test('check signature', async () => {
expect(await verifySignature(event)).toBeTruthy()
})
test('sign event', async () => {
let sig = await signEvent(unsigned, privateKey)
let hash = getEventHash(unsigned)
let pubkey = getPublicKey(privateKey)
let signed = {...unsigned, id: hash, sig, pubkey}
expect(await verifySignature(signed)).toBeTruthy()
})

View File

@@ -1,69 +0,0 @@
import * as secp256k1 from '@noble/secp256k1'
import {sha256} from '@noble/hashes/sha256'
import {utf8Encoder} from './utils'
export type Event = {
id?: string
sig?: string
kind: number
tags: string[][]
pubkey: string
content: string
created_at: number
}
export function getBlankEvent(): Event {
return {
kind: 255,
pubkey: '',
content: '',
tags: [],
created_at: 0
}
}
export function serializeEvent(evt: Event): string {
return JSON.stringify([
0,
evt.pubkey,
evt.created_at,
evt.kind,
evt.tags,
evt.content
])
}
export function getEventHash(event: Event): string {
let eventHash = sha256(utf8Encoder.encode(serializeEvent(event)))
return secp256k1.utils.bytesToHex(eventHash)
}
export function validateEvent(event: Event): boolean {
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: Event & {id: string; sig: string}
): Promise<boolean> {
return secp256k1.schnorr.verify(event.sig, event.id, event.pubkey)
}
export async function signEvent(event: Event, key: string): Promise<string> {
return secp256k1.utils.bytesToHex(
await secp256k1.schnorr.sign(getEventHash(event), key)
)
}

44
fakejson.test.ts Normal file
View File

@@ -0,0 +1,44 @@
import { test, expect } from 'bun:test'
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,42 +0,0 @@
/* eslint-env jest */
const {matchFilters} = require('./lib/nostr.cjs')
test('test if filters match', () => {
;[
{
filters: [{ids: ['i']}],
good: [{id: 'i'}],
bad: [{id: 'j'}]
},
{
filters: [{authors: ['abc']}, {kinds: [1, 3]}],
good: [
{pubkey: 'xyz', kind: 3},
{pubkey: 'abc', kind: 12},
{pubkey: 'abc', kind: 1}
],
bad: [{pubkey: 'hhh', kind: 12}]
},
{
filters: [{'#e': ['yyy'], since: 444}],
good: [
{
tags: [
['e', 'uuu'],
['e', 'yyy']
],
created_at: 555
}
],
bad: [{tags: [['e', 'uuu']], created_at: 111}]
}
].forEach(({filters, good, bad}) => {
good.forEach(ev => {
expect(matchFilters(filters, ev)).toBeTruthy()
})
bad.forEach(ev => {
expect(matchFilters(filters, ev)).toBeFalsy()
})
})
})

244
filter.test.ts Normal file
View File

@@ -0,0 +1,244 @@
import { describe, test, expect } from 'bun:test'
import { matchFilter, matchFilters, mergeFilters } from './filter.ts'
import { buildEvent } from './test-helpers.ts'
describe('Filter', () => {
describe('matchFilter', () => {
test('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)
})
test('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)
})
test('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)
})
test('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)
})
test('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)
})
test('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)
})
test('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)
})
test('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)
})
test('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)
})
test('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)
})
test('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)
})
test('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', () => {
test('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)
})
test('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)
})
test('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)
})
test('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)
})
test('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', () => {
test('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 })
})
})
})

View File

@@ -1,4 +1,4 @@
import {Event} from './event'
import { Event } from './pure.ts'
export type Filter = {
ids?: string[]
@@ -7,44 +7,66 @@ export type Filter = {
since?: number
until?: number
limit?: number
[key: `#${string}`]: string[]
search?: string
[key: `#${string}`]: string[] | undefined
}
export function matchFilter(
filter: Filter,
event: Event & {id: string}
): boolean {
if (filter.ids && filter.ids.indexOf(event.id) === -1) return false
export function matchFilter(filter: Filter, event: Event): 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)
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 (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
if (filter.until && event.created_at > filter.until) return false
return true
}
export function matchFilters(
filters: Filter[],
event: Event & {id: string}
): boolean {
export function matchFilters(filters: Filter[], event: Event): boolean {
for (let i = 0; i < filters.length; i++) {
if (matchFilter(filters[i], event)) return true
}
return false
}
export function mergeFilters(...filters: Filter[]): Filter {
let result: Filter = {}
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
}

9
helpers.ts Normal file
View File

@@ -0,0 +1,9 @@
export async function yieldThread() {
return new Promise(resolve => {
const ch = new MessageChannel()
// @ts-ignore (typescript thinks this property should be called `addListener`, but in fact it's `addEventListener`)
ch.port1.addEventListener('message', resolve)
ch.port2.postMessage(0)
ch.port1.start()
})
}

View File

@@ -1,9 +1,27 @@
export * from './keys'
export * from './relay'
export * from './event'
export * from './filter'
export * from './pure.ts'
export * from './relay.ts'
export * from './pure.ts'
export * from './filter.ts'
export * from './pool.ts'
export * from './references.ts'
export * as nip04 from './nip04'
export * as nip05 from './nip05'
export * as nip06 from './nip06'
export * as nip19 from './nip19'
export * as nip04 from './nip04.ts'
export * as nip05 from './nip05.ts'
export * as nip10 from './nip10.ts'
export * as nip11 from './nip11.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 nip27 from './nip27.ts'
export * as nip28 from './nip28.ts'
export * as nip30 from './nip30.ts'
export * as nip39 from './nip39.ts'
export * as nip42 from './nip42.ts'
export * as nip47 from './nip47.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'

25
justfile Normal file
View File

@@ -0,0 +1,25 @@
export PATH := "./node_modules/.bin:" + env_var('PATH')
build:
rm -rf lib
bun run build.js
test:
bun test --timeout 20000
test-only file:
bun test {{file}}
emit-types:
tsc # see tsconfig.json
publish: build emit-types
npm publish
format:
eslint --ext .ts --fix *.ts
prettier --write *.ts
lint:
eslint --ext .ts *.ts
prettier --check *.ts

View File

@@ -1,20 +0,0 @@
/* eslint-env jest */
const {generatePrivateKey, getPublicKey} = require('./lib/nostr.cjs')
test('test private key generation', () => {
expect(generatePrivateKey()).toMatch(/[a-f0-9]{64}/)
})
test('test public key generation', () => {
expect(getPublicKey(generatePrivateKey())).toMatch(/[a-f0-9]{64}/)
})
test('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)
}
})

View File

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

21
kinds.test.ts Normal file
View File

@@ -0,0 +1,21 @@
import { test, expect } from 'bun:test'
import { classifyKind } from './kinds.ts'
test('kind classification', () => {
expect(classifyKind(1)).toBe('regular')
expect(classifyKind(5)).toBe('regular')
expect(classifyKind(6)).toBe('regular')
expect(classifyKind(7)).toBe('regular')
expect(classifyKind(1000)).toBe('regular')
expect(classifyKind(9999)).toBe('regular')
expect(classifyKind(0)).toBe('replaceable')
expect(classifyKind(3)).toBe('replaceable')
expect(classifyKind(10000)).toBe('replaceable')
expect(classifyKind(19999)).toBe('replaceable')
expect(classifyKind(20000)).toBe('ephemeral')
expect(classifyKind(29999)).toBe('ephemeral')
expect(classifyKind(30000)).toBe('parameterized')
expect(classifyKind(39999)).toBe('parameterized')
expect(classifyKind(40000)).toBe('unknown')
expect(classifyKind(255)).toBe('unknown')
})

105
kinds.ts Normal file
View File

@@ -0,0 +1,105 @@
/** Events are **regular**, which means they're all expected to be stored by relays. */
export function isRegularKind(kind: number) {
return (1000 <= kind && kind < 10000) || [1, 2, 4, 5, 6, 7, 8, 16, 40, 41, 42, 43, 44].includes(kind)
}
/** Events are **replaceable**, which means that, for each combination of `pubkey` and `kind`, only the latest event is expected to (SHOULD) be stored by relays, older versions are expected to be discarded. */
export function isReplaceableKind(kind: number) {
return [0, 3].includes(kind) || (10000 <= kind && kind < 20000)
}
/** Events are **ephemeral**, which means they are not expected to be stored by relays. */
export function isEphemeralKind(kind: number) {
return 20000 <= kind && kind < 30000
}
/** Events are **parameterized replaceable**, which means that, for each combination of `pubkey`, `kind` and the `d` tag, only the latest event is expected to be stored by relays, older versions are expected to be discarded. */
export function isParameterizedReplaceableKind(kind: number) {
return 30000 <= kind && kind < 40000
}
/** Classification of the event kind. */
export type KindClassification = 'regular' | 'replaceable' | 'ephemeral' | 'parameterized' | 'unknown'
/** Determine the classification of this kind of event if known, or `unknown`. */
export function classifyKind(kind: number): KindClassification {
if (isRegularKind(kind)) return 'regular'
if (isReplaceableKind(kind)) return 'replaceable'
if (isEphemeralKind(kind)) return 'ephemeral'
if (isParameterizedReplaceableKind(kind)) return 'parameterized'
return 'unknown'
}
export const Metadata = 0
export const ShortTextNote = 1
export const RecommendRelay = 2
export const Contacts = 3
export const EncryptedDirectMessage = 4
export const EventDeletion = 5
export const Repost = 6
export const Reaction = 7
export const BadgeAward = 8
export const ChannelCreation = 40
export const ChannelMetadata = 41
export const ChannelMessage = 42
export const ChannelHideMessage = 43
export const ChannelMuteUser = 44
export const Report = 1984
export const ZapRequest = 9734
export const Zap = 9735
export const RelayList = 10002
export const ClientAuth = 22242
export const BadgeDefinition = 30009
export const FileMetadata = 1063
export const EncryptedDirectMessages = 4
export const GenericRepost = 16
export const OpenTimestamps = 1040
export const LiveChatMessage = 1311
export const ProblemTracker = 1971
export const Reporting = 1984
export const Label = 1985
export const CommunityPostApproval = 4550
export const JobRequest = 5999
export const JobResult = 6999
export const JobFeedback = 7000
export const ZapGoal = 9041
export const Highlights = 9802
export const Mutelist = 10000
export const Pinlist = 10001
export const BookmarkList = 10003
export const CommunitiesList = 10004
export const PublicChatsList = 10005
export const BlockedRelaysList = 10006
export const SearchRelaysList = 10007
export const InterestsList = 10015
export const UserEmojiList = 10030
export const NWCWalletInfo = 13194
export const LightningPubRPC = 21000
export const NWCWalletRequest = 23194
export const NWCWalletResponse = 23195
export const NostrConnect = 24133
export const HTTPAuth = 27235
export const Followsets = 30000
export const Genericlists = 30001
export const Relaysets = 30002
export const Bookmarksets = 30003
export const Curationsets = 30004
export const ProfileBadges = 30008
export const Interestsets = 30015
export const CreateOrUpdateStall = 30017
export const CreateOrUpdateProduct = 30018
export const LongFormArticle = 30023
export const DraftLong = 30024
export const Emojisets = 30030
export const Application = 30078
export const LiveEvent = 30311
export const UserStatuses = 30315
export const ClassifiedListing = 30402
export const DraftClassifiedListing = 30403
export const Date = 31922
export const Time = 31923
export const Calendar = 31924
export const CalendarEventRSVP = 31925
export const Handlerrecommendation = 31989
export const Handlerinformation = 31990
export const CommunityDefinition = 34550

View File

@@ -1,15 +0,0 @@
/* eslint-env jest */
globalThis.crypto = require('crypto')
const {nip04, getPublicKey, generatePrivateKey} = require('./lib/nostr.cjs')
test('encrypt and decrypt message', async () => {
let sk1 = generatePrivateKey()
let sk2 = generatePrivateKey()
let pk1 = getPublicKey(sk1)
let pk2 = getPublicKey(sk2)
expect(
await nip04.decrypt(sk2, pk1, await nip04.encrypt(sk1, pk2, 'hello'))
).toEqual('hello')
})

50
nip04.test.ts Normal file
View File

@@ -0,0 +1,50 @@
import { test, expect } from 'bun:test'
import crypto from 'node:crypto'
import { encrypt, decrypt } from './nip04.ts'
import { getPublicKey, generateSecretKey } from './pure.ts'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
try {
// @ts-ignore
// eslint-disable-next-line no-undef
globalThis.crypto = crypto
} catch (err) {
/***/
}
test('encrypt and decrypt message', async () => {
let sk1 = generateSecretKey()
let sk2 = generateSecretKey()
let pk1 = getPublicKey(sk1)
let pk2 = getPublicKey(sk2)
let ciphertext = await encrypt(bytesToHex(sk1), pk2, 'hello')
expect(await decrypt(bytesToHex(sk2), pk1, ciphertext)).toEqual('hello')
})
test('decrypt message from go-nostr', async () => {
let sk1 = '91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe'
let sk2 = '96f6fa197aa07477ab88f6981118466ae3a982faab8ad5db9d5426870c73d220'
let pk1 = getPublicKey(hexToBytes(sk1))
let ciphertext = 'zJxfaJ32rN5Dg1ODjOlEew==?iv=EV5bUjcc4OX2Km/zPp4ndQ=='
expect(await decrypt(sk2, pk1, ciphertext)).toEqual('nanana')
})
test('decrypt big payload from go-nostr', async () => {
let sk1 = '91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe'
let sk2 = '96f6fa197aa07477ab88f6981118466ae3a982faab8ad5db9d5426870c73d220'
let pk1 = getPublicKey(hexToBytes(sk1))
let ciphertext =
'6f8dMstm+udOu7yipSn33orTmwQpWbtfuY95NH+eTU1kArysWJIDkYgI2D25EAGIDJsNd45jOJ2NbVOhFiL3ZP/NWsTwXokk34iyHyA/lkjzugQ1bHXoMD1fP/Ay4hB4al1NHb8HXHKZaxPrErwdRDb8qa/I6dXb/1xxyVvNQBHHvmsM5yIFaPwnCN1DZqXf2KbTA/Ekz7Hy+7R+Sy3TXLQDFpWYqykppkXc7Fs0qSuPRyxz5+anuN0dxZa9GTwTEnBrZPbthKkNRrvZMdTGJ6WumOh9aUq8OJJWy9aOgsXvs7qjN1UqcCqQqYaVnEOhCaqWNDsVtsFrVDj+SaLIBvCiomwF4C4nIgngJ5I69tx0UNI0q+ZnvOGQZ7m1PpW2NYP7Yw43HJNdeUEQAmdCPnh/PJwzLTnIxHmQU7n7SPlMdV0SFa6H8y2HHvex697GAkyE5t8c2uO24OnqIwF1tR3blIqXzTSRl0GA6QvrSj2p4UtnWjvF7xT7RiIEyTtgU/AsihTrXyXzWWZaIBJogpgw6erlZqWjCH7sZy/WoGYEiblobOAqMYxax6vRbeuGtoYksr/myX+x9rfLrYuoDRTw4woXOLmMrrj+Mf0TbAgc3SjdkqdsPU1553rlSqIEZXuFgoWmxvVQDtekgTYyS97G81TDSK9nTJT5ilku8NVq2LgtBXGwsNIw/xekcOUzJke3kpnFPutNaexR1VF3ohIuqRKYRGcd8ADJP2lfwMcaGRiplAmFoaVS1YUhQwYFNq9rMLf7YauRGV4BJg/t9srdGxf5RoKCvRo+XM/nLxxysTR9MVaEP/3lDqjwChMxs+eWfLHE5vRWV8hUEqdrWNZV29gsx5nQpzJ4PARGZVu310pQzc6JAlc2XAhhFk6RamkYJnmCSMnb/RblzIATBi2kNrCVAlaXIon188inB62rEpZGPkRIP7PUfu27S/elLQHBHeGDsxOXsBRo1gl3te+raoBHsxo6zvRnYbwdAQa5taDE63eh+fT6kFI+xYmXNAQkU8Dp0MVhEh4JQI06Ni/AKrvYpC95TXXIphZcF+/Pv/vaGkhG2X9S3uhugwWK?iv=2vWkOQQi0WynNJz/aZ4k2g=='
let plaintext = ''
for (let i = 0; i < 800; i++) {
plaintext += 'z'
}
expect(await decrypt(sk2, pk1, ciphertext)).toEqual(plaintext)
})

View File

@@ -1,61 +1,41 @@
import {randomBytes} from '@noble/hashes/utils'
import * as secp256k1 from '@noble/secp256k1'
import {encode as b64encode, decode as b64decode} from 'base64-arraybuffer'
import { bytesToHex, randomBytes } from '@noble/hashes/utils'
import { secp256k1 } from '@noble/curves/secp256k1'
import { base64 } from '@scure/base'
import {utf8Decoder, utf8Encoder} from './utils'
import { utf8Decoder, utf8Encoder } from './utils.ts'
export async function encrypt(
privkey: string,
pubkey: string,
text: string
): Promise<string> {
// @ts-ignore
if (typeof crypto !== 'undefined' && !crypto.subtle && crypto.webcrypto) {
// @ts-ignore
crypto.subtle = crypto.webcrypto.subtle
}
export async function encrypt(secretKey: string | Uint8Array, pubkey: string, text: string): Promise<string> {
const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey
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 = b64encode(ciphertext)
let ivb64 = b64encode(iv.buffer)
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> {
export async function decrypt(secretKey: string | Uint8Array, pubkey: string, data: string): Promise<string> {
const privkey: string = secretKey instanceof Uint8Array ? bytesToHex(secretKey) : secretKey
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 = b64decode(ctb64)
let iv = b64decode(ivb64)
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 plaintext = await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, cryptoKey, ciphertext)
let text = utf8Decoder.decode(plaintext)
return text

View File

@@ -1,20 +0,0 @@
/* eslint-env jest */
const fetch = require('node-fetch')
const {nip05} = require('./lib/nostr.cjs')
test('fetch nip05 profiles', async () => {
nip05.useFetchImplementation(fetch)
let p1 = await nip05.queryProfile('jb55.com')
expect(p1.pubkey).toEqual(
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
)
expect(p1.relays).toEqual(['wss://relay.damus.io'])
let p2 = await nip05.queryProfile('jb55@jb55.com')
expect(p2.pubkey).toEqual(
'32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245'
)
expect(p2.relays).toEqual(['wss://relay.damus.io'])
})

20
nip05.test.ts Normal file
View File

@@ -0,0 +1,20 @@
import { test, expect } from 'bun:test'
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://pyramid.fiatjaf.com', 'wss://nos.lol'])
})

View File

@@ -1,4 +1,13 @@
import {ProfilePointer} from './nip19'
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
@@ -10,14 +19,9 @@ export function useFetchImplementation(fetchImplementation: any) {
_fetch = fetchImplementation
}
export async function searchDomain(
domain: string,
query = ''
): Promise<{[name: string]: string}> {
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()
let res = await (await _fetch(`https://${domain}/.well-known/nostr.json?name=${query}`)).json()
return res.names
} catch (_) {
@@ -25,30 +29,53 @@ export async function searchDomain(
}
}
export async function queryProfile(
fullname: string
): Promise<ProfilePointer | null> {
let [name, domain] = fullname.split('@')
export async function queryProfile(fullname: string): Promise<ProfilePointer | null> {
const match = fullname.match(NIP05_REGEX)
if (!match) return null
if (!domain) {
// if there is no @, it is because it is just a domain, so assume the name is "_"
domain = name
name = '_'
}
const [_, name = '_', domain] = match
if (!name.match(/^[a-z0-9-_]+$/)) return null
try {
const res = await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
const { names, relays } = parseNIP05Result(await res.json())
let res = await (
await _fetch(`https://${domain}/.well-known/nostr.json?name=${name}`)
).json()
if (!res?.names?.[name]) return null
let pubkey = res.names[name] as string
let relays = (res.relays?.[pubkey] || []) as string[] // nip35
return {
pubkey,
relays
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
}

28
nip06.test.ts Normal file
View File

@@ -0,0 +1,28 @@
import { test, expect } from 'bun:test'
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 for account 1 from a mnemonic', async () => {
const mnemonic = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong'
const privateKey = privateKeyFromSeedWords(mnemonic, undefined, 1)
expect(privateKey).toEqual('b5fc7f229de3fb5c189063e3b3fc6c921d8f4366cff5bd31c6f063493665eb2b')
})
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')
})
test('generate private key for account 1 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, 1)
expect(privateKey).toEqual('2e0f7bd9e3c3ebcdff1a90fb49c913477e7c055eba1a415d571b6a8c714c7135')
})

View File

@@ -1,17 +1,13 @@
import * as secp256k1 from '@noble/secp256k1'
import {wordlist} from '@scure/bip39/wordlists/english.js'
import {
generateMnemonic,
mnemonicToSeedSync,
validateMnemonic
} from '@scure/bip39'
import {HDKey} from '@scure/bip32'
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): string {
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic))
let privateKey = root.derive(`m/44'/1237'/0'/0/0`).privateKey
export function privateKeyFromSeedWords(mnemonic: string, passphrase?: string, accountIndex = 0): string {
let root = HDKey.fromMasterSeed(mnemonicToSeedSync(mnemonic, passphrase))
let privateKey = root.derive(`m/44'/1237'/${accountIndex}'/0/0`).privateKey
if (!privateKey) throw new Error('could not derive private key')
return secp256k1.utils.bytesToHex(privateKey)
return bytesToHex(privateKey)
}
export function generateSeedWords(): string {

229
nip10.test.ts Normal file
View File

@@ -0,0 +1,229 @@
import { describe, test, expect } from 'bun:test'
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('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,
})
})
})

91
nip10.ts Normal file
View File

@@ -0,0 +1,91 @@
import type { Event } from './pure.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
}

16
nip11.test.ts Normal file
View File

@@ -0,0 +1,16 @@
import { describe, test, expect } from 'bun:test'
import fetch from 'node-fetch'
import { useFetchImplementation, fetchRelayInformation } from './nip11'
describe('requesting relay as for NIP11', () => {
useFetchImplementation(fetch)
test('testing a relay', async () => {
const info = await fetchRelayInformation('wss://atlas.nostr.land')
expect(info.name).toEqual('nostr.land')
expect(info.description).toEqual('nostr.land family of relays (us-or-01)')
expect(info.fees).toBeTruthy()
expect(info.supported_nips).toEqual([1, 2, 4, 9, 11, 12, 16, 20, 22, 28, 33, 40])
expect(info.software).toEqual('custom')
})
})

286
nip11.ts Normal file
View File

@@ -0,0 +1,286 @@
var _fetch: any
try {
_fetch = fetch
} catch {}
export function useFetchImplementation(fetchImplementation: any) {
_fetch = fetchImplementation
}
export async function fetchRelayInformation(url: string) {
return (await (
await fetch(url.replace('ws://', 'http://').replace('wss://', 'https://'), {
headers: { Accept: 'application/nostr+json' },
})
).json()) as RelayInformation
}
/**
* ## Relay Information Document
* Relays may provide server metadata to clients to inform
* them of capabilities, administrative contacts, and
* various server attributes. This is made available as a
* JSON document over HTTP, on the same URI as the relay's
* websocket.
* Any field may be omitted, and clients MUST ignore any
* additional fields they do not understand. Relays MUST
* accept CORS requests by sending
* `Access-Control-Allow-Origin`,
* `Access-Control-Allow-Headers`, and
* `Access-Control-Allow-Methods` headers.
* @param name string identifying relay
* @param description string with detailed information
* @param pubkey administrative contact pubkey
* @param contact: administrative alternate contact
* @param supported_nips a list of NIP numbers supported by
* the relay
* @param software identifying relay software URL
* @param version string version identifier
*/
export interface BasicRelayInformation {
// string identifying relay
name: string
description: string
pubkey: string
contact: string
supported_nips: number[]
software: string
version: string
// limitation?: Limitations<A, P>
}
/**
* * ## Extra Fields
* * ### Server Limitations
* These are limitations imposed by the relay on clients.
* Your client should expect that requests which exceed
* these practical_ limitations are rejected or fail immediately.
* @param max_message_length this is the maximum number of
* bytes for incoming JSON that the relay will attempt to
* decode and act upon. When you send large subscriptions,
* you will be limited by this value. It also effectively
* limits the maximum size of any event. Value is calculated
* from `[` to `]` and is after UTF-8 serialization (so some
* unicode characters will cost 2-3 bytes). It is equal to
* the maximum size of the WebSocket message frame.
* @param max_subscription total number of subscriptions
* that may be active on a single websocket connection to
* this relay. It's possible that authenticated clients with
* a (paid) relationship to the relay may have higher limits.
* @param max_filters maximum number of filter values in
* each subscription. Must be one or higher.
* @param max_limit the relay server will clamp each
* filter's `limit` value to this number.
* This means the client won't be able to get more than this
* number of events from a single subscription filter. This
* clamping is typically done silently by the relay, but
* with this number, you can know that there are additional
* results if you narrowed your filter's time range or other
* parameters.
* @param max_subid_length maximum length of subscription id as a
* string.
* @param min_prefix for `authors` and `ids` filters which
* are to match against a hex prefix, you must provide at
* least this many hex digits in the prefix.
* @param max_event_tags in any event, this is the maximum
* number of elements in the `tags` list.
* @param max_content_length maximum number of characters in
* the `content` field of any event. This is a count of
* unicode characters. After serializing into JSON it may be
* larger (in bytes), and is still subject to the
* max_message_length`, if defined.
* @param min_pow_difficulty new events will require at
* least this difficulty of PoW, based on [NIP-13](13.md),
* or they will be rejected by this server.
* @param auth_required this relay requires [NIP-42](42.md)
* authentication to happen before a new connection may
* perform any other action. Even if set to False,
* authentication may be required for specific actions.
* @param payment_required this relay requires payment
* before a new connection may perform any action.
*/
export interface Limitations {
max_message_length: number
max_subscription: number
max_filters: number
max_limit: number
max_subid_length: number
min_prefix: number
max_event_tags: number
max_content_length: number
min_pow_difficulty: number
auth_required: boolean
payment_required: boolean
}
interface RetentionDetails {
kinds: (number | number[])[]
time?: number | null
count?: number | null
}
type AnyRetentionDetails = RetentionDetails
/**
* ### Event Retention
* There may be a cost associated with storing data forever,
* so relays may wish to state retention times. The values
* stated here are defaults for unauthenticated users and
* visitors. Paid users would likely have other policies.
* Retention times are given in seconds, with `null`
* indicating infinity. If zero is provided, this means the
* event will not be stored at all, and preferably an error
* will be provided when those are received.
* ```json
{
...
"retention": [
{ "kinds": [0, 1, [5, 7], [40, 49]], "time": 3600 },
{ "kinds": [[40000, 49999]], "time": 100 },
{ "kinds": [[30000, 39999]], "count": 1000 },
{ "time": 3600, "count": 10000 }
]
...
}
```
* @param retention is a list of specifications: each will
* apply to either all kinds, or a subset of kinds. Ranges
* may be specified for the kind field as a tuple of
* inclusive start and end values. Events of indicated kind
* (or all) are then limited to a `count` and/or time
* period.
* It is possible to effectively blacklist Nostr-based
* protocols that rely on a specific `kind` number, by
* giving a retention time of zero for those `kind` values.
* While that is unfortunate, it does allow clients to
* discover servers that will support their protocol quickly
* via a single HTTP fetch.
* There is no need to specify retention times for
* _ephemeral events_ as defined in [NIP-16](16.md) since
* they are not retained.
*/
export interface Retention {
retention: AnyRetentionDetails[]
}
/**
* Some relays may be governed by the arbitrary laws of a
* nation state. This may limit what content can be stored
* in cleartext on those relays. All clients are encouraged
* to use encryption to work around this limitation.
* It is not possible to describe the limitations of each
* country's laws and policies which themselves are
* typically vague and constantly shifting.
* Therefore, this field allows the relay operator to
* indicate which countries' laws might end up being
* enforced on them, and then indirectly on their users'
* content.
* Users should be able to avoid relays in countries they
* don't like, and/or select relays in more favourable
* zones. Exposing this flexibility is up to the client
* software.
* @param relay_countries a list of two-level ISO country
* codes (ISO 3166-1 alpha-2) whose laws and policies may
* affect this relay. `EU` may be used for European Union
* countries.
* Remember that a relay may be hosted in a country which is
* not the country of the legal entities who own the relay,
* so it's very likely a number of countries are involved.
*/
export interface ContentLimitations {
relay_countries: string[]
}
/**
* ### Community Preferences
* For public text notes at least, a relay may try to foster
* a local community. This would encourage users to follow
* the global feed on that relay, in addition to their usual
* individual follows. To support this goal, relays MAY
* specify some of the following values.
* @param language_tags is an ordered list of [IETF
* language
* tags](https://en.wikipedia.org/wiki/IETF_language_tag
* indicating the major languages spoken on the relay.
* @param tags is a list of limitations on the topics to be
* discussed. For example `sfw-only` indicates that only
* "Safe For Work" content is encouraged on this relay. This
* relies on assumptions of what the "work" "community"
* feels "safe" talking about. In time, a common set of tags
* may emerge that allow users to find relays that suit
* their needs, and client software will be able to parse
* these tags easily. The `bitcoin-only` tag indicates that
* any _altcoin_, _"crypto"_ or _blockchain_ comments will
* be ridiculed without mercy.
* @param posting_policy is a link to a human-readable page
* which specifies the community policies for the relay. In
* cases where `sfw-only` is True, it's important to link to
* a page which gets into the specifics of your posting
* policy.
* The `description` field should be used to describe your
* community goals and values, in brief. The
* `posting_policy` is for additional detail and legal
* terms. Use the `tags` field to signify limitations on
* content, or topics to be discussed, which could be
* machine processed by appropriate client software.
*/
export interface CommunityPreferences {
language_tags: string[]
tags: string[]
posting_policy: string
}
export interface Amount {
amount: number
unit: 'msat'
}
export interface PublicationAmount extends Amount {
kinds: number[]
}
export interface Subscription extends Amount {
period: number
}
export interface Fees {
admission: Amount[]
subscription: Subscription[]
publication: PublicationAmount[]
}
/**
* Relays that require payments may want to expose their fee
* schedules.
*/
export interface PayToRelay {
payments_url: string
fees: Fees
}
/**
* A URL pointing to an image to be used as an icon for the
* relay. Recommended to be squared in shape.
*/
export interface Icon {
icon: string
}
export type RelayInformation = BasicRelayInformation &
Partial<Retention> & {
limitation?: Partial<Limitations>
} & Partial<ContentLimitations> &
Partial<CommunityPreferences> &
Partial<PayToRelay> &
Partial<Icon>

25
nip13.test.ts Normal file
View File

@@ -0,0 +1,25 @@
import { test, expect } from 'bun:test'
import { getPow, minePow } from './nip13.ts'
test('identifies proof-of-work difficulty', async () => {
const id = '000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358'
const difficulty = getPow(id)
expect(difficulty).toEqual(21)
})
test('mines POW for an event', async () => {
const difficulty = 10
const event = minePow(
{
kind: 1,
tags: [],
content: 'Hello, world!',
created_at: 0,
pubkey: '79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6',
},
difficulty,
)
expect(getPow(event.id)).toBeGreaterThanOrEqual(difficulty)
})

52
nip13.ts Normal file
View File

@@ -0,0 +1,52 @@
import { type UnsignedEvent, type Event, getEventHash } from './pure.ts'
/** Get POW difficulty from a Nostr hex ID. */
export function getPow(hex: string): number {
let count = 0
for (let i = 0; i < hex.length; i++) {
const nibble = parseInt(hex[i], 16)
if (nibble === 0) {
count += 4
} else {
count += Math.clz32(nibble) - 28
break
}
}
return count
}
/**
* Mine an event with the desired POW. This function mutates the event.
* Note that this operation is synchronous and should be run in a worker context to avoid blocking the main thread.
*
* Adapted from Snort: https://git.v0l.io/Kieran/snort/src/commit/4df6c19248184218c4c03728d61e94dae5f2d90c/packages/system/src/pow-util.ts#L14-L36
*/
export function minePow(unsigned: UnsignedEvent, difficulty: number): Omit<Event, 'sig'> {
let count = 0
const event = unsigned as Omit<Event, 'sig'>
const tag = ['nonce', count.toString(), difficulty.toString()]
event.tags.push(tag)
while (true) {
const now = Math.floor(new Date().getTime() / 1000)
if (now !== event.created_at) {
count = 0
event.created_at = now
}
tag[1] = (++count).toString()
event.id = getEventHash(event)
if (getPow(event.id) >= difficulty) {
break
}
}
return event
}

102
nip18.test.ts Normal file
View File

@@ -0,0 +1,102 @@
import { describe, test, expect } from 'bun:test'
import { hexToBytes } from '@noble/hashes/utils'
import { finalizeEvent, getPublicKey } from './pure.ts'
import { Repost, ShortTextNote } from './kinds.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 = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const repostedEvent = finalizeEvent(
{
kind: ShortTextNote,
tags: [
['e', 'replied event id'],
['p', 'replied event pubkey'],
],
content: 'Replied to a post',
created_at: 1617932115,
},
privateKey,
)
test('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(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)
})
test('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(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).toBeUndefined()
})
})
describe('getRepostedEventPointer', () => {
test('should parse an event with only an `e` tag', () => {
const event = buildEvent({
kind: Repost,
tags: [['e', 'reposted event id', relayUrl]],
})
const repostedEventPointer = getRepostedEventPointer(event)
expect(repostedEventPointer!.id).toEqual('reposted event id')
expect(repostedEventPointer!.author).toBeUndefined()
expect(repostedEventPointer!.relays).toEqual([relayUrl])
})
})

97
nip18.ts Normal file
View File

@@ -0,0 +1,97 @@
import { Event, finalizeEvent, verifyEvent } from './pure.ts'
import { Repost } from './kinds.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,
relayUrl: string,
privateKey: Uint8Array,
): Event {
return finalizeEvent(
{
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): undefined | EventPointer {
if (event.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, { skipVerification }: GetRepostedEventOptions = {}): undefined | Event {
const pointer = getRepostedEventPointer(event)
if (pointer === undefined || event.content === '') {
return undefined
}
let repostedEvent: undefined | Event
try {
repostedEvent = JSON.parse(event.content) as Event
} catch (error) {
return undefined
}
if (repostedEvent.id !== pointer.id) {
return undefined
}
if (!skipVerification && !verifyEvent(repostedEvent)) {
return undefined
}
return repostedEvent
}

View File

@@ -1,36 +0,0 @@
/* eslint-env jest */
const {nip19, generatePrivateKey, getPublicKey} = require('./lib/nostr.cjs')
test('encode and decode nsec', () => {
let sk = generatePrivateKey()
let nsec = nip19.nsecEncode(sk)
expect(nsec).toMatch(/nsec1\w+/)
let {type, data} = nip19.decode(nsec)
expect(type).toEqual('nsec')
expect(data).toEqual(sk)
})
test('encode and decode npub', () => {
let pk = getPublicKey(generatePrivateKey())
let npub = nip19.npubEncode(pk)
expect(npub).toMatch(/npub1\w+/)
let {type, data} = nip19.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 = nip19.nprofileEncode({pubkey: pk, relays})
expect(nprofile).toMatch(/nprofile1\w+/)
let {type, data} = nip19.decode(nprofile)
expect(type).toEqual('nprofile')
expect(data.pubkey).toEqual(pk)
expect(data.relays).toContain(relays[0])
expect(data.relays).toContain(relays[1])
})

144
nip19.test.ts Normal file
View File

@@ -0,0 +1,144 @@
import { test, expect } from 'bun:test'
import { generateSecretKey, getPublicKey } from './pure.ts'
import {
decode,
naddrEncode,
nprofileEncode,
npubEncode,
nrelayEncode,
nsecEncode,
neventEncode,
type AddressPointer,
type ProfilePointer,
EventPointer,
} from './nip19.ts'
test('encode and decode nsec', () => {
let sk = generateSecretKey()
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(generateSecretKey())
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(generateSecretKey())
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(generateSecretKey())
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('encode and decode nevent', () => {
let pk = getPublicKey(generateSecretKey())
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
let naddr = neventEncode({
id: pk,
relays,
kind: 30023,
})
expect(naddr).toMatch(/nevent1\w+/)
let { type, data } = decode(naddr)
expect(type).toEqual('nevent')
const pointer = data as EventPointer
expect(pointer.id).toEqual(pk)
expect(pointer.relays).toContain(relays[0])
expect(pointer.kind).toEqual(30023)
})
test('encode and decode nevent with kind 0', () => {
let pk = getPublicKey(generateSecretKey())
let relays = ['wss://relay.nostr.example.mydomain.example.com', 'wss://nostr.banana.com']
let naddr = neventEncode({
id: pk,
relays,
kind: 0,
})
expect(naddr).toMatch(/nevent1\w+/)
let { type, data } = decode(naddr)
expect(type).toEqual('nevent')
const pointer = data as EventPointer
expect(pointer.id).toEqual(pk)
expect(pointer.relays).toContain(relays[0])
expect(pointer.kind).toEqual(0)
})
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)
})

226
nip19.ts
View File

@@ -1,7 +1,28 @@
import * as secp256k1 from '@noble/secp256k1'
import {bech32} from 'bech32'
import { bytesToHex, concatBytes, hexToBytes } from '@noble/hashes/utils'
import { bech32 } from '@scure/base'
import {utf8Decoder, utf8Encoder} from './utils'
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,}/
function integerToUint8Array(number: number) {
// Create a Uint8Array with enough space to hold a 32-bit integer (4 bytes).
const uint8Array = new Uint8Array(4)
// Use bitwise operations to extract the bytes.
uint8Array[0] = (number >> 24) & 0xff // Most significant byte (MSB)
uint8Array[1] = (number >> 16) & 0xff
uint8Array[2] = (number >> 8) & 0xff
uint8Array[3] = number & 0xff // Least significant byte (LSB)
return uint8Array
}
export type ProfilePointer = {
pubkey: string // hex
@@ -11,51 +32,116 @@ export type ProfilePointer = {
export type EventPointer = {
id: string // hex
relays?: string[]
author?: string
kind?: number
}
export function decode(nip19: string): {
type: string
data: ProfilePointer | EventPointer | string
} {
let {prefix, words} = bech32.decode(nip19, 1000)
export type AddressPointer = {
identifier: string
pubkey: string
kind: number
relays?: string[]
}
type Prefixes = {
nprofile: ProfilePointer
nrelay: string
nevent: EventPointer
naddr: AddressPointer
nsec: Uint8Array
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))
if (prefix === 'nprofile') {
let tlv = parseTLV(data)
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nprofile')
if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes')
switch (prefix) {
case 'nprofile': {
let tlv = parseTLV(data)
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nprofile')
if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes')
return {
type: 'nprofile',
data: {
pubkey: secp256k1.utils.bytesToHex(tlv[0][0]),
relays: tlv[1].map(d => utf8Decoder.decode(d))
return {
type: 'nprofile',
data: {
pubkey: bytesToHex(tlv[0][0]),
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
},
}
}
}
case 'nevent': {
let tlv = parseTLV(data)
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nevent')
if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes')
if (tlv[2] && tlv[2][0].length !== 32) throw new Error('TLV 2 should be 32 bytes')
if (tlv[3] && tlv[3][0].length !== 4) throw new Error('TLV 3 should be 4 bytes')
if (prefix === 'nevent') {
let tlv = parseTLV(data)
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for nevent')
if (tlv[0][0].length !== 32) throw new Error('TLV 0 should be 32 bytes')
return {
type: 'nevent',
data: {
id: secp256k1.utils.bytesToHex(tlv[0][0]),
relays: tlv[1].map(d => utf8Decoder.decode(d))
return {
type: 'nevent',
data: {
id: bytesToHex(tlv[0][0]),
relays: tlv[1] ? tlv[1].map(d => utf8Decoder.decode(d)) : [],
author: tlv[2]?.[0] ? bytesToHex(tlv[2][0]) : undefined,
kind: tlv[3]?.[0] ? parseInt(bytesToHex(tlv[3][0]), 16) : undefined,
},
}
}
}
if (prefix === 'nsec' || prefix === 'npub' || prefix === 'note') {
return {type: prefix, data: secp256k1.utils.bytesToHex(data)}
}
case 'naddr': {
let tlv = parseTLV(data)
if (!tlv[0]?.[0]) throw new Error('missing TLV 0 for naddr')
if (!tlv[2]?.[0]) throw new Error('missing TLV 2 for naddr')
if (tlv[2][0].length !== 32) throw new Error('TLV 2 should be 32 bytes')
if (!tlv[3]?.[0]) throw new Error('missing TLV 3 for naddr')
if (tlv[3][0].length !== 4) throw new Error('TLV 3 should be 4 bytes')
throw new Error(`unknown prefix ${prefix}`)
return {
type: 'naddr',
data: {
identifier: utf8Decoder.decode(tlv[0][0]),
pubkey: 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':
return { type: prefix, data }
case 'npub':
case 'note':
return { type: prefix, data: bytesToHex(data) }
default:
throw new Error(`unknown prefix ${prefix}`)
}
}
type TLV = {[t: number]: Uint8Array[]}
type TLV = { [t: number]: Uint8Array[] }
function parseTLV(data: Uint8Array): TLV {
let result: TLV = {}
@@ -63,49 +149,79 @@ function parseTLV(data: Uint8Array): TLV {
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) continue
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): string {
return encodeBytes('nsec', hex)
export function nsecEncode(key: Uint8Array): `nsec1${string}` {
return encodeBytes('nsec', key)
}
export function npubEncode(hex: string): string {
return encodeBytes('npub', hex)
export function npubEncode(hex: string): `npub1${string}` {
return encodeBytes('npub', hexToBytes(hex))
}
export function noteEncode(hex: string): string {
return encodeBytes('note', hex)
export function noteEncode(hex: string): `note1${string}` {
return encodeBytes('note', hexToBytes(hex))
}
function encodeBytes(prefix: string, hex: string): string {
let data = secp256k1.utils.hexToBytes(hex)
function encodeBech32<Prefix extends string>(prefix: Prefix, data: Uint8Array): `${Prefix}1${string}` {
let words = bech32.toWords(data)
return bech32.encode(prefix, words, 1000)
return bech32.encode(prefix, words, Bech32MaxSize) as `${Prefix}1${string}`
}
export function nprofileEncode(profile: ProfilePointer): string {
function encodeBytes<Prefix extends string>(prefix: Prefix, bytes: Uint8Array): `${Prefix}1${string}` {
return encodeBech32(prefix, bytes)
}
export function nprofileEncode(profile: ProfilePointer): `nprofile1${string}` {
let data = encodeTLV({
0: [secp256k1.utils.hexToBytes(profile.pubkey)],
1: (profile.relays || []).map(url => utf8Encoder.encode(url))
0: [hexToBytes(profile.pubkey)],
1: (profile.relays || []).map(url => utf8Encoder.encode(url)),
})
let words = bech32.toWords(data)
return bech32.encode('nprofile', words, 1000)
return encodeBech32('nprofile', data)
}
export function neventEncode(event: EventPointer): string {
export function neventEncode(event: EventPointer): `nevent1${string}` {
let kindArray
if (event.kind !== undefined) {
kindArray = integerToUint8Array(event.kind)
}
let data = encodeTLV({
0: [secp256k1.utils.hexToBytes(event.id)],
1: (event.relays || []).map(url => utf8Encoder.encode(url))
0: [hexToBytes(event.id)],
1: (event.relays || []).map(url => utf8Encoder.encode(url)),
2: event.author ? [hexToBytes(event.author)] : [],
3: kindArray ? [new Uint8Array(kindArray)] : [],
})
let words = bech32.toWords(data)
return bech32.encode('nevent', words, 1000)
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 {
@@ -121,5 +237,5 @@ function encodeTLV(tlv: TLV): Uint8Array {
})
})
return secp256k1.utils.concatBytes(...entries)
return concatBytes(...entries)
}

24
nip21.test.ts Normal file
View File

@@ -0,0 +1,24 @@
import { test, expect } from 'bun:test'
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',
},
})
})

30
nip21.ts Normal file
View File

@@ -0,0 +1,30 @@
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 { describe, test, expect } from 'bun:test'
import { hexToBytes } from '@noble/hashes/utils'
import { finalizeEvent, getPublicKey } from './pure.ts'
import { Reaction, ShortTextNote } from './kinds.ts'
import { finishReactionEvent, getReactedEventPointer } from './nip25.ts'
describe('finishReactionEvent + getReactedEventPointer', () => {
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
const reactedEvent = finalizeEvent(
{
kind: ShortTextNote,
tags: [
['e', 'replied event id'],
['p', 'replied event pubkey'],
],
content: 'Replied to a post',
created_at: 1617932115,
},
privateKey,
)
test('should create a signed event from a minimal template', () => {
const template = {
created_at: 1617932115,
}
const event = finishReactionEvent(template, reactedEvent, privateKey)
expect(event.kind).toEqual(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)
})
test('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(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)
})
})

62
nip25.ts Normal file
View File

@@ -0,0 +1,62 @@
import { Event, finalizeEvent } from './pure.ts'
import { Reaction } from './kinds.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, privateKey: Uint8Array): Event {
const inheritedTags = reacted.tags.filter(tag => tag.length >= 2 && (tag[0] === 'e' || tag[0] === 'p'))
return finalizeEvent(
{
...t,
kind: Reaction,
tags: [...(t.tags ?? []), ...inheritedTags, ['e', reacted.id], ['p', reacted.pubkey]],
content: t.content ?? '+',
},
privateKey,
)
}
export function getReactedEventPointer(event: Event): undefined | EventPointer {
if (event.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],
}
}

68
nip27.test.ts Normal file
View File

@@ -0,0 +1,68 @@
import { test, expect } from 'bun:test'
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')
})

63
nip27.ts Normal file
View File

@@ -0,0 +1,63 @@
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),
})
})
}

130
nip28.test.ts Normal file
View File

@@ -0,0 +1,130 @@
import { describe, test, expect } from 'bun:test'
import { hexToBytes } from '@noble/hashes/utils'
import { getPublicKey } from './pure.ts'
import * as Kind from './kinds.ts'
import {
channelCreateEvent,
channelMetadataEvent,
channelMessageEvent,
channelHideMessageEvent,
channelMuteUserEvent,
ChannelMetadata,
ChannelMessageEventTemplate,
} from './nip28.ts'
const privateKey = hexToBytes('d217c1ff2f8a65c3e3a1740db3b9f58b8c848bb45e26d00ed4714e4a0f4ceecf')
const publicKey = getPublicKey(privateKey)
describe('NIP-28 Functions', () => {
const channelMetadata: ChannelMetadata = {
name: 'Test Channel',
about: 'This is a test channel',
picture: 'https://example.com/picture.jpg',
}
test('channelCreateEvent should create an event with given template', () => {
const template = {
content: channelMetadata,
created_at: 1617932115,
}
const event = channelCreateEvent(template, privateKey)
expect(event!.kind).toEqual(Kind.ChannelCreation)
expect(event!.content).toEqual(JSON.stringify(template.content))
expect(event!.pubkey).toEqual(publicKey)
})
test('channelMetadataEvent should create a signed event with given template', () => {
const template = {
channel_create_event_id: 'channel creation event id',
content: channelMetadata,
created_at: 1617932115,
}
const event = channelMetadataEvent(template, privateKey)
expect(event!.kind).toEqual(Kind.ChannelMetadata)
expect(event!.tags).toEqual([['e', template.channel_create_event_id]])
expect(event!.content).toEqual(JSON.stringify(template.content))
expect(event!.pubkey).toEqual(publicKey)
expect(typeof event!.id).toEqual('string')
expect(typeof event!.sig).toEqual('string')
})
test('channelMessageEvent should create a signed message event with given template', () => {
const template: ChannelMessageEventTemplate = {
channel_create_event_id: 'channel creation event id',
relay_url: 'https://relay.example.com',
content: 'Hello, world!',
created_at: 1617932115,
}
const event = channelMessageEvent(template, privateKey)
expect(event.kind).toEqual(Kind.ChannelMessage)
expect(event.tags[0]).toEqual(['e', template.channel_create_event_id, template.relay_url, 'root'])
expect(event.content).toEqual(template.content)
expect(event.pubkey).toEqual(publicKey)
expect(typeof event.id).toEqual('string')
expect(typeof event.sig).toEqual('string')
})
test('channelMessageEvent should create a signed message reply event with given template', () => {
const template: ChannelMessageEventTemplate = {
channel_create_event_id: 'channel creation event id',
reply_to_channel_message_event_id: 'channel message event id',
relay_url: 'https://relay.example.com',
content: 'Hello, world!',
created_at: 1617932115,
}
const event = channelMessageEvent(template, privateKey)
expect(event.kind).toEqual(Kind.ChannelMessage)
expect(event.tags.find(tag => tag[0] === 'e' && tag[1] === template.channel_create_event_id)).toEqual([
'e',
template.channel_create_event_id,
template.relay_url,
'root',
])
expect(event.tags.find(tag => tag[0] === 'e' && tag[1] === template.reply_to_channel_message_event_id)).toEqual([
'e',
template.reply_to_channel_message_event_id as string,
template.relay_url,
'reply',
])
expect(event.content).toEqual(template.content)
expect(event.pubkey).toEqual(publicKey)
expect(typeof event.id).toEqual('string')
expect(typeof event.sig).toEqual('string')
})
test('channelHideMessageEvent should create a signed event with given template', () => {
const template = {
channel_message_event_id: 'channel message event id',
content: { reason: 'Inappropriate content' },
created_at: 1617932115,
}
const event = channelHideMessageEvent(template, privateKey)
expect(event!.kind).toEqual(Kind.ChannelHideMessage)
expect(event!.tags).toEqual([['e', template.channel_message_event_id]])
expect(event!.content).toEqual(JSON.stringify(template.content))
expect(event!.pubkey).toEqual(publicKey)
expect(typeof event!.id).toEqual('string')
expect(typeof event!.sig).toEqual('string')
})
test('channelMuteUserEvent should create a signed event with given template', () => {
const template = {
content: { reason: 'Spamming' },
created_at: 1617932115,
pubkey_to_mute: 'pubkey to mute',
}
const event = channelMuteUserEvent(template, privateKey)
expect(event!.kind).toEqual(Kind.ChannelMuteUser)
expect(event!.tags).toEqual([['p', template.pubkey_to_mute]])
expect(event!.content).toEqual(JSON.stringify(template.content))
expect(event!.pubkey).toEqual(publicKey)
expect(typeof event!.id).toEqual('string')
expect(typeof event!.sig).toEqual('string')
})
})

152
nip28.ts Normal file
View File

@@ -0,0 +1,152 @@
import { Event, finalizeEvent } from './pure.ts'
import { ChannelCreation, ChannelHideMessage, ChannelMessage, ChannelMetadata, ChannelMuteUser } from './kinds.ts'
export interface ChannelMetadata {
name: string
about: string
picture: string
}
export interface ChannelCreateEventTemplate {
/* JSON string containing ChannelMetadata as defined for Kind 40 and 41 in nip-28. */
content: string | ChannelMetadata
created_at: number
tags?: string[][]
}
export interface ChannelMetadataEventTemplate {
channel_create_event_id: string
/* JSON string containing ChannelMetadata as defined for Kind 40 and 41 in nip-28. */
content: string | ChannelMetadata
created_at: number
tags?: string[][]
}
export interface ChannelMessageEventTemplate {
channel_create_event_id: string
reply_to_channel_message_event_id?: string
relay_url: string
content: string
created_at: number
tags?: string[][]
}
export interface ChannelHideMessageEventTemplate {
channel_message_event_id: string
content: string | { reason: string }
created_at: number
tags?: string[][]
}
export interface ChannelMuteUserEventTemplate {
content: string | { reason: string }
created_at: number
pubkey_to_mute: string
tags?: string[][]
}
export const channelCreateEvent = (t: ChannelCreateEventTemplate, privateKey: Uint8Array): Event | undefined => {
let content: string
if (typeof t.content === 'object') {
content = JSON.stringify(t.content)
} else if (typeof t.content === 'string') {
content = t.content
} else {
return undefined
}
return finalizeEvent(
{
kind: ChannelCreation,
tags: [...(t.tags ?? [])],
content: content,
created_at: t.created_at,
},
privateKey,
)
}
export const channelMetadataEvent = (t: ChannelMetadataEventTemplate, privateKey: Uint8Array): Event | undefined => {
let content: string
if (typeof t.content === 'object') {
content = JSON.stringify(t.content)
} else if (typeof t.content === 'string') {
content = t.content
} else {
return undefined
}
return finalizeEvent(
{
kind: ChannelMetadata,
tags: [['e', t.channel_create_event_id], ...(t.tags ?? [])],
content: content,
created_at: t.created_at,
},
privateKey,
)
}
export const channelMessageEvent = (t: ChannelMessageEventTemplate, privateKey: Uint8Array): Event => {
const tags = [['e', t.channel_create_event_id, t.relay_url, 'root']]
if (t.reply_to_channel_message_event_id) {
tags.push(['e', t.reply_to_channel_message_event_id, t.relay_url, 'reply'])
}
return finalizeEvent(
{
kind: ChannelMessage,
tags: [...tags, ...(t.tags ?? [])],
content: t.content,
created_at: t.created_at,
},
privateKey,
)
}
/* "e" tag should be the kind 42 event to hide */
export const channelHideMessageEvent = (
t: ChannelHideMessageEventTemplate,
privateKey: Uint8Array,
): Event | undefined => {
let content: string
if (typeof t.content === 'object') {
content = JSON.stringify(t.content)
} else if (typeof t.content === 'string') {
content = t.content
} else {
return undefined
}
return finalizeEvent(
{
kind: ChannelHideMessage,
tags: [['e', t.channel_message_event_id], ...(t.tags ?? [])],
content: content,
created_at: t.created_at,
},
privateKey,
)
}
export const channelMuteUserEvent = (t: ChannelMuteUserEventTemplate, privateKey: Uint8Array): Event | undefined => {
let content: string
if (typeof t.content === 'object') {
content = JSON.stringify(t.content)
} else if (typeof t.content === 'string') {
content = t.content
} else {
return undefined
}
return finalizeEvent(
{
kind: ChannelMuteUser,
tags: [['p', t.pubkey_to_mute], ...(t.tags ?? [])],
content: content,
created_at: t.created_at,
},
privateKey,
)
}

33
nip30.test.ts Normal file
View File

@@ -0,0 +1,33 @@
import { test, expect } from 'bun:test'
import { matchAll, replaceAll } from './nip30.ts'
test('matchAll', () => {
const result = matchAll('Hello :blobcat: :disputed: ::joy:joy:')
expect([...result]).toEqual([
{
name: 'blobcat',
shortcode: ':blobcat:',
start: 6,
end: 15,
},
{
name: 'disputed',
shortcode: ':disputed:',
start: 16,
end: 26,
},
])
})
test('replaceAll', () => {
const content = 'Hello :blobcat: :disputed: ::joy:joy:'
const result = replaceAll(content, ({ name }) => {
return `<img src="https://ditto.pub/emoji/${name}.png" />`
})
expect(result).toEqual(
'Hello <img src="https://ditto.pub/emoji/blobcat.png" /> <img src="https://ditto.pub/emoji/disputed.png" /> ::joy:joy:',
)
})

51
nip30.ts Normal file
View File

@@ -0,0 +1,51 @@
/** Regex for a single emoji shortcode. */
export const EMOJI_SHORTCODE_REGEX = /:(\w+):/
/** Regex to find emoji shortcodes in content. */
export const regex = () => new RegExp(`\\B${EMOJI_SHORTCODE_REGEX.source}\\B`, 'g')
/** Represents a Nostr custom emoji. */
export interface CustomEmoji {
/** The matched emoji name with colons. */
shortcode: `:${string}:`
/** The matched emoji name without colons. */
name: string
}
/** Match result for a custom emoji in text content. */
export interface CustomEmojiMatch extends CustomEmoji {
/** Index where the emoji begins in the text content. */
start: number
/** Index where the emoji ends in the text content. */
end: number
}
/** Find all custom emoji shortcodes. */
export function* matchAll(content: string): Iterable<CustomEmojiMatch> {
const matches = content.matchAll(regex())
for (const match of matches) {
try {
const [shortcode, name] = match
yield {
shortcode: shortcode as `:${string}:`,
name,
start: match.index!,
end: match.index! + shortcode.length,
}
} catch (_e) {
// do nothing
}
}
}
/** Replace all emoji shortcodes in the content. */
export function replaceAll(content: string, replacer: (match: CustomEmoji) => string): string {
return content.replaceAll(regex(), (shortcode, name) => {
return replacer({
shortcode: shortcode as `:${string}:`,
name,
})
})
}

15
nip39.test.ts Normal file
View File

@@ -0,0 +1,15 @@
import { test, expect } from 'bun:test'
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)
})

18
nip39.ts Normal file
View File

@@ -0,0 +1,18 @@
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
}
}

14
nip42.test.ts Normal file
View File

@@ -0,0 +1,14 @@
import { test, expect } from 'bun:test'
import { makeAuthEvent } from './nip42.ts'
import { relayConnect } from './relay.ts'
test('auth flow', () => {
const relay = relayConnect('wss://nostr.wine')
const auth = makeAuthEvent(relay.url, 'chachacha')
expect(auth.tags).toHaveLength(2)
expect(auth.tags[0]).toEqual(['relay', 'wss://nostr.wine/'])
expect(auth.tags[1]).toEqual(['challenge', 'chachacha'])
expect(auth.kind).toEqual(22242)
})

17
nip42.ts Normal file
View File

@@ -0,0 +1,17 @@
import { EventTemplate } from './pure.ts'
import { ClientAuth } from './kinds.ts'
/**
* creates an EventTemplate for an AUTH event to be signed.
*/
export function makeAuthEvent(relayURL: string, challenge: string): EventTemplate {
return {
kind: ClientAuth,
created_at: Math.floor(Date.now() / 1000),
tags: [
['relay', relayURL],
['challenge', challenge],
],
content: '',
}
}

65
nip47.test.ts Normal file
View File

@@ -0,0 +1,65 @@
import crypto from 'node:crypto'
import { describe, test, expect } from 'bun:test'
import { hexToBytes } from '@noble/hashes/utils'
import { makeNwcRequestEvent, parseConnectionString } from './nip47'
import { decrypt } from './nip04.ts'
import { NWCWalletRequest } from './kinds.ts'
// @ts-ignore
// eslint-disable-next-line no-undef
globalThis.crypto = crypto
describe('parseConnectionString', () => {
test('returns pubkey, relay, and secret if connection string is valid', () => {
const connectionString =
'nostr+walletconnect:b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c'
const { pubkey, relay, secret } = parseConnectionString(connectionString)
expect(pubkey).toBe('b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4')
expect(relay).toBe('wss://relay.damus.io')
expect(secret).toBe('71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c')
})
test('throws an error if no pubkey in connection string', async () => {
const connectionString =
'nostr+walletconnect:relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c'
expect(() => parseConnectionString(connectionString)).toThrow('invalid connection string')
})
test('throws an error if no relay in connection string', async () => {
const connectionString =
'nostr+walletconnect:b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c'
expect(() => parseConnectionString(connectionString)).toThrow('invalid connection string')
})
test('throws an error if no secret in connection string', async () => {
const connectionString =
'nostr+walletconnect:b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io'
expect(() => parseConnectionString(connectionString)).toThrow('invalid connection string')
})
})
describe('makeNwcRequestEvent', () => {
test('returns a valid NWC request event', async () => {
const pubkey = 'b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4'
const secret = hexToBytes('71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c')
const invoice =
'lnbc210n1pjdgyvupp5x43awdarnfd4mdlsklelux0nyckwfu5c708ykuet8vcjnjp3rnpqdqu2askcmr9wssx7e3q2dshgmmndp5scqzzsxqyz5vqsp52l7y9peq9pka3vd3j7aps7gjnalsmy46ndj2mlkz00dltjgqfumq9qyyssq5fasr5dxed8l4qjfnqq48a02jzss3asf8sly7sfaqtr9w3yu2q9spsxhghs3y9aqdf44zkrrg9jjjdg6amade4h0hulllkwk33eqpucp6d5jye'
const result = await makeNwcRequestEvent(pubkey, secret, invoice)
expect(result.kind).toBe(NWCWalletRequest)
expect(await decrypt(secret, pubkey, result.content)).toEqual(
JSON.stringify({
method: 'pay_invoice',
params: {
invoice,
},
}),
)
expect(result.tags).toEqual([['p', pubkey]])
expect(result.id).toEqual(expect.any(String))
expect(result.sig).toEqual(expect.any(String))
})
})

34
nip47.ts Normal file
View File

@@ -0,0 +1,34 @@
import { finalizeEvent } from './pure.ts'
import { NWCWalletRequest } from './kinds.ts'
import { encrypt } from './nip04.ts'
export function parseConnectionString(connectionString: string) {
const { pathname, searchParams } = new URL(connectionString)
const pubkey = pathname
const relay = searchParams.get('relay')
const secret = searchParams.get('secret')
if (!pubkey || !relay || !secret) {
throw new Error('invalid connection string')
}
return { pubkey, relay, secret }
}
export async function makeNwcRequestEvent(pubkey: string, secretKey: Uint8Array, invoice: string) {
const content = {
method: 'pay_invoice',
params: {
invoice,
},
}
const encryptedContent = await encrypt(secretKey, pubkey, JSON.stringify(content))
const eventTemplate = {
kind: NWCWalletRequest,
created_at: Math.round(Date.now() / 1000),
content: encryptedContent,
tags: [['p', pubkey]],
}
return finalizeEvent(eventTemplate, secretKey)
}

319
nip57.test.ts Normal file
View File

@@ -0,0 +1,319 @@
import { describe, test, expect, mock } from 'bun:test'
import { finalizeEvent } from './pure.ts'
import { getPublicKey, generateSecretKey } from './pure.ts'
import { getZapEndpoint, makeZapReceipt, makeZapRequest, useFetchImplementation, validateZapRequest } from './nip57.ts'
import { 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 = mock(() => 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 = mock(() => 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 = mock(() =>
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'],
['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 = generateSecretKey()
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 = generateSecretKey()
const zapRequest = finalizeEvent(
{
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 = generateSecretKey()
const zapRequest = finalizeEvent(
{
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 = generateSecretKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = finalizeEvent(
{
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 = generateSecretKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = finalizeEvent(
{
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 = generateSecretKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = finalizeEvent(
{
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 = generateSecretKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = JSON.stringify(
finalizeEvent(
{
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).toEqual(
expect.arrayContaining([
['bolt11', bolt11],
['description', zapRequest],
['p', publicKey],
['preimage', preimage],
]),
)
})
test('returns a valid Zap receipt without a preimage', () => {
const privateKey = generateSecretKey()
const publicKey = getPublicKey(privateKey)
const zapRequest = JSON.stringify(
finalizeEvent(
{
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).toEqual(
expect.arrayContaining([
['bolt11', bolt11],
['description', zapRequest],
['p', publicKey],
]),
)
expect(JSON.stringify(result.tags)).not.toContain('preimage')
})
})

130
nip57.ts Normal file
View File

@@ -0,0 +1,130 @@
import { bech32 } from '@scure/base'
import { validateEvent, verifyEvent, type Event, type EventTemplate } from './pure.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): Promise<null | string> {
try {
let lnurl: string = ''
let { lud06, lud16 } = JSON.parse(metadata.content)
if (lud06) {
let { words } = bech32.decode(lud06, 1000)
let data = bech32.fromWords(words)
lnurl = utf8Decoder.decode(data)
} else if (lud16) {
let [name, domain] = lud16.split('@')
lnurl = `https://${domain}/.well-known/lnurlp/${name}`
} else {
return null
}
let res = await _fetch(lnurl)
let body = await res.json()
if (body.allowsNostr && body.nostrPubkey) {
return body.callback
}
} catch (err) {
/*-*/
}
return null
}
export function makeZapRequest({
profile,
event,
amount,
relays,
comment = '',
}: {
profile: string
event: string | null
amount: number
comment: string
relays: string[]
}): EventTemplate {
if (!amount) throw new Error('amount not given')
if (!profile) throw new Error('profile not given')
let zr: EventTemplate = {
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 (!verifyEvent(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 {
let zr: Event = JSON.parse(zapRequest)
let tagsFromZapRequest = zr.tags.filter(([t]) => t === 'e' || t === 'p' || t === 'a')
let zap: EventTemplate = {
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
}

159
nip98.test.ts Normal file
View File

@@ -0,0 +1,159 @@
import { describe, test, expect } from 'bun:test'
import { getToken, unpackEventFromToken, validateEvent, validateToken } from './nip98.ts'
import { Event, finalizeEvent } from './pure.ts'
import { generateSecretKey, getPublicKey } from './pure.ts'
import { sha256 } from '@noble/hashes/sha256'
import { utf8Encoder } from './utils.ts'
import { bytesToHex } from '@noble/hashes/utils'
import { HTTPAuth } from './kinds.ts'
const sk = generateSecretKey()
describe('getToken', () => {
test('getToken GET returns without authorization scheme', async () => {
let result = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
const decodedResult: Event = await unpackEventFromToken(result)
expect(decodedResult.created_at).toBeGreaterThan(0)
expect(decodedResult.content).toBe('')
expect(decodedResult.kind).toBe(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 => finalizeEvent(e, sk))
const decodedResult: Event = await unpackEventFromToken(result)
expect(decodedResult.created_at).toBeGreaterThan(0)
expect(decodedResult.content).toBe('')
expect(decodedResult.kind).toBe(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 => finalizeEvent(e, sk), true)
expect(result.startsWith(authorizationScheme)).toBe(true)
const decodedResult: Event = await unpackEventFromToken(result)
expect(decodedResult.created_at).toBeGreaterThan(0)
expect(decodedResult.content).toBe('')
expect(decodedResult.kind).toBe(HTTPAuth)
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
expect(decodedResult.tags).toStrictEqual([
['u', 'http://test.com'],
['method', 'post'],
])
})
test('getToken returns token with a valid payload tag when payload is present', async () => {
const payload = { test: 'payload' }
const payloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))
let result = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, payload)
const decodedResult: Event = await unpackEventFromToken(result)
expect(decodedResult.created_at).toBeGreaterThan(0)
expect(decodedResult.content).toBe('')
expect(decodedResult.kind).toBe(HTTPAuth)
expect(decodedResult.pubkey).toBe(getPublicKey(sk))
expect(decodedResult.tags).toStrictEqual([
['u', 'http://test.com'],
['method', 'post'],
['payload', payloadHash],
])
})
})
describe('validateToken', () => {
test('validateToken returns true for valid token without authorization scheme', async () => {
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(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 => finalizeEvent(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')
expect(result).rejects.toThrow(Error)
})
test('validateToken throws an error for missing token', async () => {
const result = validateToken('', 'http://test.com', 'get')
expect(result).rejects.toThrow(Error)
})
test('validateToken throws an error for a wrong url', async () => {
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
const result = validateToken(validToken, 'http://wrong-test.com', 'get')
expect(result).rejects.toThrow(Error)
})
test('validateToken throws an error for a wrong method', async () => {
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk))
const result = validateToken(validToken, 'http://test.com', 'post')
expect(result).rejects.toThrow(Error)
})
test('validateEvent returns true for valid decoded token with authorization scheme', async () => {
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const decodedResult: Event = await unpackEventFromToken(validToken)
const result = await validateEvent(decodedResult, 'http://test.com', 'get')
expect(result).toBe(true)
})
test('validateEvent throws an error for a wrong url', async () => {
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const decodedResult: Event = await unpackEventFromToken(validToken)
const result = validateEvent(decodedResult, 'http://wrong-test.com', 'get')
expect(result).rejects.toThrow(Error)
})
test('validateEvent throws an error for a wrong method', async () => {
const validToken = await getToken('http://test.com', 'get', e => finalizeEvent(e, sk), true)
const decodedResult: Event = await unpackEventFromToken(validToken)
const result = validateEvent(decodedResult, 'http://test.com', 'post')
expect(result).rejects.toThrow(Error)
})
test('validateEvent returns true for valid payload tag hash', async () => {
const validToken = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'payload' })
const decodedResult: Event = await unpackEventFromToken(validToken)
const result = await validateEvent(decodedResult, 'http://test.com', 'post', { test: 'payload' })
expect(result).toBe(true)
})
test('validateEvent returns false for invalid payload tag hash', async () => {
const validToken = await getToken('http://test.com', 'post', e => finalizeEvent(e, sk), true, { test: 'a-payload' })
const decodedResult: Event = await unpackEventFromToken(validToken)
const result = validateEvent(decodedResult, 'http://test.com', 'post', { test: 'a-different-payload' })
expect(result).rejects.toThrow(Error)
})
})

121
nip98.ts Normal file
View File

@@ -0,0 +1,121 @@
import { bytesToHex } from '@noble/hashes/utils'
import { sha256 } from '@noble/hashes/sha256'
import { base64 } from '@scure/base'
import { Event, EventTemplate, verifyEvent } from './pure.ts'
import { utf8Decoder, utf8Encoder } from './utils.ts'
import { HTTPAuth } from './kinds.ts'
const _authorizationScheme = 'Nostr '
export function hashPayload(payload: any): string {
const hash = sha256(utf8Encoder.encode(JSON.stringify(payload)))
return bytesToHex(hash)
}
/**
* Generate token for NIP-98 flow.
*
* @example
* const sign = window.nostr.signEvent
* await nip98.getToken('https://example.com/login', 'post', (e) => sign(e), true)
*/
export async function getToken(
loginUrl: string,
httpMethod: string,
sign: (e: EventTemplate) => Promise<Event> | Event,
includeAuthorizationScheme: boolean = false,
payload?: Record<string, any>,
): Promise<string> {
const event: EventTemplate = {
kind: HTTPAuth,
tags: [
['u', loginUrl],
['method', httpMethod],
],
created_at: Math.round(new Date().getTime() / 1000),
content: '',
}
if (payload) {
event.tags.push(['payload', bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(payload))))])
}
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 nip98.validateToken('Nostr base64token', 'https://example.com/login', 'post')
*/
export async function validateToken(token: string, url: string, method: string): Promise<boolean> {
const event = await unpackEventFromToken(token).catch(error => {
throw error
})
const valid = await validateEvent(event, url, method).catch(error => {
throw error
})
return valid
}
export async function unpackEventFromToken(token: string): Promise<Event> {
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
return event
}
export async function validateEvent(event: Event, url: string, method: string, body?: any): Promise<boolean> {
if (!event) {
throw new Error('Invalid nostr event')
}
if (!verifyEvent(event)) {
throw new Error('Invalid nostr event, signature invalid')
}
if (event.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')
}
if (Boolean(body) && Object.keys(body).length > 0) {
const payloadTag = event.tags.find(t => t[0] === 'payload')
const payloadHash = bytesToHex(sha256(utf8Encoder.encode(JSON.stringify(body))))
if (payloadTag?.[1] !== payloadHash) {
throw new Error('Invalid payload tag hash, does not match request body hash')
}
}
return true
}

View File

@@ -1,20 +1,167 @@
{
"name": "nostr-tools",
"version": "1.0.0-rc2",
"version": "2.0.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"
},
"main": "lib/nostr.cjs.js",
"module": "lib/nostr.esm.js",
"files": [
"lib"
],
"sideEffects": false,
"module": "./lib/esm/index.js",
"main": "./lib/cjs/index.js",
"types": "./lib/types/index.d.ts",
"exports": {
".": {
"import": "./lib/esm/index.js",
"require": "./lib/cjs/index.js",
"types": "./lib/types/index.d.ts"
},
"./pure": {
"import": "./lib/esm/pure.js",
"require": "./lib/cjs/pure.js",
"types": "./lib/types/pure.d.ts"
},
"./wasm": {
"import": "./lib/esm/wasm.js",
"require": "./lib/cjs/wasm.js",
"types": "./lib/types/wasm.d.ts"
},
"./filter": {
"import": "./lib/esm/filter.js",
"require": "./lib/cjs/filter.js",
"types": "./lib/types/filter.d.ts"
},
"./relay": {
"import": "./lib/esm/relay.js",
"require": "./lib/cjs/relay.js",
"types": "./lib/types/relay.d.ts"
},
"./pool": {
"import": "./lib/esm/pool.js",
"require": "./lib/cjs/pool.js",
"types": "./lib/types/pool.d.ts"
},
"./references": {
"import": "./lib/esm/references.js",
"require": "./lib/cjs/references.js",
"types": "./lib/types/references.d.ts"
},
"./nip04": {
"import": "./lib/esm/nip04.js",
"require": "./lib/cjs/nip04.js",
"types": "./lib/types/nip04.d.ts"
},
"./nip05": {
"import": "./lib/esm/nip05.js",
"require": "./lib/cjs/nip05.js",
"types": "./lib/types/nip05.d.ts"
},
"./nip06": {
"import": "./lib/esm/nip06.js",
"require": "./lib/cjs/nip06.js",
"types": "./lib/types/nip06.d.ts"
},
"./nip10": {
"import": "./lib/esm/nip10.js",
"require": "./lib/cjs/nip10.js",
"types": "./lib/types/nip10.d.ts"
},
"./nip11": {
"import": "./lib/esm/nip11.js",
"require": "./lib/cjs/nip11.js",
"types": "./lib/types/nip11.d.ts"
},
"./nip13": {
"import": "./lib/esm/nip13.js",
"require": "./lib/cjs/nip13.js",
"types": "./lib/types/nip13.d.ts"
},
"./nip18": {
"import": "./lib/esm/nip18.js",
"require": "./lib/cjs/nip18.js",
"types": "./lib/types/nip18.d.ts"
},
"./nip19": {
"import": "./lib/esm/nip19.js",
"require": "./lib/cjs/nip19.js",
"types": "./lib/types/nip19.d.ts"
},
"./nip21": {
"import": "./lib/esm/nip21.js",
"require": "./lib/cjs/nip21.js",
"types": "./lib/types/nip21.d.ts"
},
"./nip25": {
"import": "./lib/esm/nip25.js",
"require": "./lib/cjs/nip25.js",
"types": "./lib/types/nip25.d.ts"
},
"./nip27": {
"import": "./lib/esm/nip27.js",
"require": "./lib/cjs/nip27.js",
"types": "./lib/types/nip27.d.ts"
},
"./nip28": {
"import": "./lib/esm/nip28.js",
"require": "./lib/cjs/nip28.js",
"types": "./lib/types/nip28.d.ts"
},
"./nip30": {
"import": "./lib/esm/nip30.js",
"require": "./lib/cjs/nip30.js",
"types": "./lib/types/nip30.d.ts"
},
"./nip39": {
"import": "./lib/esm/nip39.js",
"require": "./lib/cjs/nip39.js",
"types": "./lib/types/nip39.d.ts"
},
"./nip42": {
"import": "./lib/esm/nip42.js",
"require": "./lib/cjs/nip42.js",
"types": "./lib/types/nip42.d.ts"
},
"./nip57": {
"import": "./lib/esm/nip57.js",
"require": "./lib/cjs/nip57.js",
"types": "./lib/types/nip57.d.ts"
},
"./nip98": {
"import": "./lib/esm/nip98.js",
"require": "./lib/cjs/nip98.js",
"types": "./lib/types/nip98.d.ts"
},
"./fakejson": {
"import": "./lib/esm/fakejson.js",
"require": "./lib/cjs/fakejson.js",
"types": "./lib/types/fakejson.d.ts"
},
"./utils": {
"import": "./lib/esm/utils.js",
"require": "./lib/cjs/utils.js",
"types": "./lib/types/utils.d.ts"
}
},
"license": "Unlicense",
"dependencies": {
"@noble/hashes": "^0.5.7",
"@noble/secp256k1": "^1.7.0",
"@scure/bip32": "^1.1.1",
"@scure/bip39": "^1.1.0",
"base64-arraybuffer": "^1.0.2",
"bech32": "^2.0.0"
"@noble/ciphers": "0.2.0",
"@noble/curves": "1.2.0",
"@noble/hashes": "1.3.1",
"@scure/base": "1.1.1",
"@scure/bip32": "1.3.1",
"@scure/bip39": "1.2.1",
"nostr-wasm": "v0.0.3"
},
"peerDependencies": {
"typescript": ">=5.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
},
"keywords": [
"decentralization",
@@ -24,25 +171,24 @@
"nostr"
],
"devDependencies": {
"@types/node": "^18.0.3",
"@typescript-eslint/eslint-plugin": "^5.46.1",
"@typescript-eslint/parser": "^5.46.1",
"@types/node": "^18.13.0",
"@types/node-fetch": "^2.6.3",
"@typescript-eslint/eslint-plugin": "^6.5.0",
"@typescript-eslint/parser": "^6.5.0",
"bun-types": "^1.0.18",
"esbuild": "0.16.9",
"esbuild-plugin-alias": "^0.2.1",
"eslint": "^8.30.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-babel": "^5.3.1",
"esm-loader-typescript": "^1.0.1",
"esm-loader-typescript": "^1.0.3",
"events": "^3.3.0",
"jest": "^29.3.1",
"node-fetch": "2",
"ts-jest": "^29.0.3",
"node-fetch": "^2.6.9",
"prettier": "^3.0.3",
"tsd": "^0.22.0",
"typescript": "^4.9.4",
"websocket-polyfill": "^0.0.3"
"typescript": "^5.0.4"
},
"scripts": {
"build": "node build.js",
"pretest": "node build.js",
"test": "jest"
"prepublish": "just build && just emit-types"
}
}

99
pool.test.ts Normal file
View File

@@ -0,0 +1,99 @@
import { test, expect, afterAll } from 'bun:test'
import { finalizeEvent, type Event } from './pure.ts'
import { generateSecretKey, getPublicKey } from './pure.ts'
import { SimplePool } from './pool.ts'
let pool = new SimplePool()
let relays = ['wss://relay.damus.io/', 'wss://relay.nostr.bg/', 'wss://nos.lol', 'wss://public.relaying.io']
afterAll(() => {
pool.close([...relays, 'wss://offchain.pub', 'wss://eden.nostr.land'])
})
test('removing duplicates when querying', async () => {
let priv = generateSecretKey()
let pub = getPublicKey(priv)
pool.subscribeMany(relays, [{ authors: [pub] }], {
onevent(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 received: Event[] = []
let event = finalizeEvent(
{
created_at: Math.round(Date.now() / 1000),
content: 'test',
kind: 22345,
tags: [],
},
priv,
)
await Promise.any(pool.publish(relays, event))
await new Promise(resolve => setTimeout(resolve, 1500))
expect(received).toHaveLength(1)
expect(received[0]).toEqual(event)
})
test('same with double querying', async () => {
let priv = generateSecretKey()
let pub = getPublicKey(priv)
pool.subscribeMany(relays, [{ authors: [pub] }], {
onevent(event) {
received.push(event)
},
})
pool.subscribeMany(relays, [{ authors: [pub] }], {
onevent(event) {
received.push(event)
},
})
let received: Event[] = []
let event = finalizeEvent(
{
created_at: Math.round(Date.now() / 1000),
content: 'test2',
kind: 22346,
tags: [],
},
priv,
)
await Promise.any(pool.publish(relays, event))
await new Promise(resolve => setTimeout(resolve, 1500))
expect(received).toHaveLength(2)
})
test('querySync()', async () => {
let events = await pool.querySync([...relays.slice(2), '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).toBeGreaterThan(2)
const uniqueEventCount = new Set(events.map(evt => evt.id)).size
expect(events).toHaveLength(uniqueEventCount)
})
test('get()', async () => {
let event = await pool.get(relays, {
ids: ['9fa1c618fcaad6357e074417b07ed132b083ed30e13113ebb10fcda7137442fe'],
})
expect(event).not.toBeNull()
expect(event).toHaveProperty('id', '9fa1c618fcaad6357e074417b07ed132b083ed30e13113ebb10fcda7137442fe')
})

183
pool.ts Normal file
View File

@@ -0,0 +1,183 @@
import { Relay, SubscriptionParams, Subscription } from './relay.ts'
import { normalizeURL } from './utils.ts'
import type { Event } from './pure.ts'
import { type Filter } from './filter.ts'
export type SubCloser = { close: () => void }
export type SubscribeManyParams = Omit<SubscriptionParams, 'onclose' | 'id'> & {
maxWait?: number
onclose?: (reasons: string[]) => void
id?: string
}
export class SimplePool {
private relays = new Map<string, Relay>()
public seenOn = new Map<string, Set<Relay>>()
public trackRelays: boolean = false
public trustedRelayURLs = new Set<string>()
async ensureRelay(url: string, params?: { connectionTimeout?: number }): Promise<Relay> {
url = normalizeURL(url)
let relay = this.relays.get(url)
if (!relay) {
relay = new Relay(url)
if (params?.connectionTimeout) relay.connectionTimeout = params.connectionTimeout
if (this.trustedRelayURLs.has(relay.url)) relay.trusted = true
this.relays.set(url, relay)
await relay.connect()
}
return relay
}
close(relays: string[]) {
relays.map(normalizeURL).forEach(url => {
this.relays.get(url)?.close()
})
}
subscribeMany(relays: string[], filters: Filter[], params: SubscribeManyParams): SubCloser {
if (this.trackRelays) {
params.receivedEvent = (relay: Relay, id: string) => {
let set = this.seenOn.get(id)
if (!set) {
set = new Set()
this.seenOn.set(id, set)
}
set.add(relay)
}
}
const _knownIds = new Set<string>()
const subs: Subscription[] = []
// batch all EOSEs into a single
const eosesReceived: boolean[] = []
let handleEose = (i: number) => {
eosesReceived[i] = true
if (eosesReceived.filter(a => a).length === relays.length) {
params.oneose?.()
handleEose = () => {}
}
}
// batch all closes into a single
const closesReceived: string[] = []
let handleClose = (i: number, reason: string) => {
handleEose(i)
closesReceived[i] = reason
if (closesReceived.filter(a => a).length === relays.length) {
params.onclose?.(closesReceived)
handleClose = () => {}
}
}
const localAlreadyHaveEventHandler = (id: string) => {
if (params.alreadyHaveEvent?.(id)) {
return true
}
const have = _knownIds.has(id)
_knownIds.add(id)
return have
}
// open a subscription in all given relays
const allOpened = Promise.all(
relays.map(normalizeURL).map(async (url, i, arr) => {
if (arr.indexOf(url) !== i) {
// duplicate
handleClose(i, 'duplicate url')
return
}
let relay: Relay
try {
relay = await this.ensureRelay(url, {
connectionTimeout: params.maxWait ? Math.max(params.maxWait * 0.8, params.maxWait - 1000) : undefined,
})
} catch (err) {
handleClose(i, (err as any)?.message || String(err))
return
}
let subscription = relay.subscribe(filters, {
...params,
oneose: () => handleEose(i),
onclose: reason => handleClose(i, reason),
alreadyHaveEvent: localAlreadyHaveEventHandler,
eoseTimeout: params.maxWait,
})
subs.push(subscription)
}),
)
return {
async close() {
await allOpened
subs.forEach(sub => {
sub.close()
})
},
}
}
subscribeManyEose(
relays: string[],
filters: Filter[],
params: Pick<SubscribeManyParams, 'id' | 'onevent' | 'onclose' | 'maxWait'>,
): SubCloser {
const subcloser = this.subscribeMany(relays, filters, {
...params,
oneose() {
subcloser.close()
},
})
return subcloser
}
async querySync(
relays: string[],
filter: Filter,
params?: Pick<SubscribeManyParams, 'id' | 'maxWait'>,
): Promise<Event[]> {
return new Promise(async resolve => {
const events: Event[] = []
this.subscribeManyEose(relays, [filter], {
...params,
onevent(event: Event) {
events.push(event)
},
onclose(_: string[]) {
resolve(events)
},
})
})
}
async get(
relays: string[],
filter: Filter,
params?: Pick<SubscribeManyParams, 'id' | 'maxWait'>,
): Promise<Event | null> {
filter.limit = 1
const events = await this.querySync(relays, filter, params)
events.sort((a, b) => b.created_at - a.created_at)
return events[0] || null
}
publish(relays: string[], event: Event): Promise<string>[] {
return relays.map(normalizeURL).map(async (url, i, arr) => {
if (arr.indexOf(url) !== i) {
// duplicate
return Promise.reject('duplicate url')
}
let r = await this.ensureRelay(url)
return r.publish(event)
})
}
}

59
pure.ts Normal file
View File

@@ -0,0 +1,59 @@
import { schnorr } from '@noble/curves/secp256k1'
import { bytesToHex } from '@noble/hashes/utils'
import { Nostr, Event, EventTemplate, UnsignedEvent, VerifiedEvent, verifiedSymbol, validateEvent } from './core'
import { sha256 } from '@noble/hashes/sha256'
import { utf8Encoder } from './utils.ts'
class JS implements Nostr {
generateSecretKey(): Uint8Array {
return schnorr.utils.randomPrivateKey()
}
getPublicKey(secretKey: Uint8Array): string {
return bytesToHex(schnorr.getPublicKey(secretKey))
}
finalizeEvent(t: EventTemplate, secretKey: Uint8Array): VerifiedEvent {
const event = t as VerifiedEvent
event.pubkey = bytesToHex(schnorr.getPublicKey(secretKey))
event.id = getEventHash(event)
event.sig = bytesToHex(schnorr.sign(getEventHash(event), secretKey))
event[verifiedSymbol] = true
return event
}
verifyEvent(event: Event): event is VerifiedEvent {
if (typeof event[verifiedSymbol] === 'boolean') return event[verifiedSymbol]
const hash = getEventHash(event)
if (hash !== event.id) {
event[verifiedSymbol] = false
return false
}
try {
const valid = schnorr.verify(event.sig, hash, event.pubkey)
event[verifiedSymbol] = valid
return valid
} catch (err) {
event[verifiedSymbol] = false
return false
}
}
}
export function serializeEvent(evt: UnsignedEvent): 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): string {
let eventHash = sha256(utf8Encoder.encode(serializeEvent(event)))
return bytesToHex(eventHash)
}
const i = new JS()
export const generateSecretKey = i.generateSecretKey
export const getPublicKey = i.getPublicKey
export const finalizeEvent = i.finalizeEvent
export const verifyEvent = i.verifyEvent
export * from './core.ts'

47
references.test.ts Normal file
View File

@@ -0,0 +1,47 @@
import { test, expect } from 'bun:test'
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,
},
},
])
})

104
references.ts Normal file
View File

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

View File

@@ -1,114 +0,0 @@
/* eslint-env jest */
require('websocket-polyfill')
const {
relayInit,
generatePrivateKey,
getPublicKey,
getEventHash,
signEvent
} = require('./lib/nostr.cjs')
let relay = relayInit('wss://nostr-pub.semisol.dev/')
beforeAll(() => {
relay.connect()
})
afterAll(async () => {
await relay.close()
})
test('connectivity', () => {
return expect(
new Promise(resolve => {
relay.on('connect', () => {
resolve(true)
})
relay.on('error', () => {
resolve(false)
})
})
).resolves.toBe(true)
})
test('querying', () => {
var resolve1
var resolve2
let sub = relay.sub([
{
ids: ['d7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027']
}
])
sub.on('event', event => {
expect(event).toHaveProperty(
'id',
'd7dd5eb3ab747e16f8d0212d53032ea2a7cadef53837e5a6c66d42849fcb9027'
)
resolve1(true)
})
sub.on('eose', () => {
resolve2(true)
})
return expect(
Promise.all([
new Promise(resolve => {
resolve1 = resolve
}),
new Promise(resolve => {
resolve2 = resolve
})
])
).resolves.toEqual([true, true])
})
test('listening (twice) and publishing', async () => {
let sk = generatePrivateKey()
let pk = getPublicKey(sk)
var resolve1
var resolve2
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 = {
kind: 27572,
pubkey: pk,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'nostr-tools test suite'
}
event.id = getEventHash(event)
event.sig = await signEvent(event, sk)
relay.publish(event)
return expect(
Promise.all([
new Promise(resolve => {
resolve1 = resolve
}),
new Promise(resolve => {
resolve2 = resolve
})
])
).resolves.toEqual([true, true])
})

110
relay.test.ts Normal file
View File

@@ -0,0 +1,110 @@
import { test, expect, afterEach, beforeEach } from 'bun:test'
import { finalizeEvent } from './pure.ts'
import { generateSecretKey, getPublicKey } from './pure.ts'
import { Relay } from './relay.ts'
let relay = new Relay('wss://public.relaying.io')
beforeEach(() => {
relay.connect()
})
afterEach(() => {
relay.close()
})
test('connectivity', async () => {
await relay.connect()
expect(relay.connected).toBeTrue()
})
test('querying', async () => {
let resolve1: () => void
let resolve2: () => void
let waiting = Promise.all([
new Promise<void>(resolve => {
resolve1 = resolve
}),
new Promise<void>(resolve => {
resolve2 = resolve
}),
])
relay.subscribe(
[
{
ids: ['3abc6cbb215af0412ab2c9c8895d96a084297890fd0b4018f8427453350ca2e4'],
},
],
{
onevent(event) {
expect(event).toHaveProperty('id', '3abc6cbb215af0412ab2c9c8895d96a084297890fd0b4018f8427453350ca2e4')
expect(event).toHaveProperty('content', '+')
expect(event).toHaveProperty('kind', 7)
resolve1()
},
oneose() {
resolve2()
},
},
)
let [t1, t2] = await waiting
expect(t1).toBeUndefined()
expect(t2).toBeUndefined()
}, 10000)
test('listening and publishing and closing', async () => {
let sk = generateSecretKey()
let pk = getPublicKey(sk)
var resolve1: (_: void) => void
var resolve2: (_: void) => void
let waiting = Promise.all([
new Promise(resolve => {
resolve1 = resolve
}),
new Promise(resolve => {
resolve2 = resolve
}),
])
let sub = relay.subscribe(
[
{
kinds: [23571],
authors: [pk],
},
],
{
onevent(event) {
expect(event).toHaveProperty('pubkey', pk)
expect(event).toHaveProperty('kind', 23571)
expect(event).toHaveProperty('content', 'nostr-tools test suite')
resolve1()
},
onclose() {
resolve2()
},
},
)
let event = finalizeEvent(
{
kind: 23571,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: 'nostr-tools test suite',
},
sk,
)
await relay.publish(event)
sub.close()
let [t1, t2] = await waiting
expect(t1).toBeUndefined()
expect(t2).toBeUndefined()
})

562
relay.ts
View File

@@ -1,269 +1,351 @@
/* global WebSocket */
import {Event, verifySignature, validateEvent} from './event'
import {Filter, matchFilters} from './filter'
import { verifyEvent, validateEvent, type Event, EventTemplate } from './pure.ts'
import { matchFilters, type Filter } from './filter.ts'
import { getHex64, getSubscriptionId } from './fakejson.ts'
import { Queue, normalizeURL } from './utils.ts'
import { nip42 } from './index.ts'
import { yieldThread } from './helpers.ts'
export type Relay = {
url: string
status: number
connect: () => void
close: () => void
sub: (filters: Filter[], opts: SubscriptionOptions) => Sub
publish: (event: Event) => Pub
on: (type: 'connect' | 'disconnect' | 'notice', cb: any) => void
off: (type: 'connect' | 'disconnect' | 'notice', cb: any) => void
}
export type Pub = {
on: (type: 'ok' | 'seen' | 'failed', cb: any) => void
off: (type: 'ok' | 'seen' | 'failed', cb: any) => void
}
export type Sub = {
sub: (filters: Filter[], opts: SubscriptionOptions) => Sub
unsub: () => void
on: (type: 'event' | 'eose', cb: any) => void
off: (type: 'event' | 'eose', cb: any) => void
export function relayConnect(url: string) {
const relay = new Relay(url)
relay.connect()
return relay
}
type SubscriptionOptions = {
skipVerification?: boolean
id?: string
}
export class Relay {
public readonly url: string
private _connected: boolean = false
export function relayInit(url: string): Relay {
var ws: WebSocket
var resolveClose: () => void
var untilOpen: Promise<void>
var openSubs: {[id: string]: {filters: Filter[]} & SubscriptionOptions} = {}
var listeners: {
connect: Array<() => void>
disconnect: Array<() => void>
error: Array<() => void>
notice: Array<(msg: string) => void>
} = {
connect: [],
disconnect: [],
error: [],
notice: []
public trusted: boolean = false
public onclose: (() => void) | null = null
public onnotice: (msg: string) => void = msg => console.debug(`NOTICE from ${this.url}: ${msg}`)
public baseEoseTimeout: number = 4400
public connectionTimeout: number = 4400
private connectionTimeoutHandle: ReturnType<typeof setTimeout> | undefined
private connectionPromise: Promise<void> | undefined
private openSubs = new Map<string, Subscription>()
private openCountRequests = new Map<string, CountResolver>()
private openEventPublishes = new Map<string, EventPublishResolver>()
private ws: WebSocket | undefined
private incomingMessageQueue = new Queue<string>()
private queueRunning = false
private challenge: string | undefined
private serial: number = 0
constructor(url: string) {
this.url = normalizeURL(url)
}
var subListeners: {
[subid: string]: {
event: Array<(event: Event) => void>
eose: Array<() => void>
}
} = {}
var pubListeners: {
[eventid: string]: {
ok: Array<() => void>
seen: Array<() => void>
failed: Array<(reason: string) => void>
}
} = {}
async function connectRelay(): Promise<void> {
return new Promise((resolve, reject) => {
ws = new WebSocket(url)
private closeAllSubscriptions(reason: string) {
for (let [_, sub] of this.openSubs) {
sub.close(reason)
}
this.openSubs.clear()
ws.onopen = () => {
listeners.connect.forEach(cb => cb())
for (let [_, ep] of this.openEventPublishes) {
ep.reject(new Error(reason))
}
this.openEventPublishes.clear()
for (let [_, cr] of this.openCountRequests) {
cr.reject(new Error(reason))
}
this.openCountRequests.clear()
}
public get connected(): boolean {
return this._connected
}
public async connect(): Promise<void> {
if (this.connectionPromise) return this.connectionPromise
this.challenge = undefined
this.connectionPromise = new Promise((resolve, reject) => {
this.connectionTimeoutHandle = setTimeout(() => {
reject('connection timed out')
this.connectionPromise = undefined
this.onclose?.()
this.closeAllSubscriptions('relay connection timed out')
}, this.connectionTimeout)
try {
this.ws = new WebSocket(this.url)
} catch (err) {
reject(err)
return
}
this.ws.onopen = () => {
clearTimeout(this.connectionTimeoutHandle)
this._connected = true
resolve()
}
ws.onerror = () => {
listeners.error.forEach(cb => cb())
reject()
}
ws.onclose = async () => {
listeners.disconnect.forEach(cb => cb())
resolveClose()
}
ws.onmessage = async e => {
var data
try {
data = JSON.parse(e.data)
} catch (err) {
data = e.data
this.ws.onerror = ev => {
reject((ev as any).message)
if (this._connected) {
this.onclose?.()
this.closeAllSubscriptions('relay connection errored')
this._connected = false
}
}
if (data.length >= 1) {
switch (data[0]) {
case 'EVENT':
if (data.length !== 3) return // ignore empty or malformed EVENT
this.ws.onclose = async () => {
this.connectionPromise = undefined
this.onclose?.()
this.closeAllSubscriptions('relay connection closed')
this._connected = false
}
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 'EOSE': {
if (data.length !== 2) return // ignore empty or malformed EOSE
let id = data[1]
;(subListeners[id]?.eose || []).forEach(cb => cb())
return
}
case 'OK': {
if (data.length < 3) return // ignore empty or malformed OK
let id: string = data[1]
let ok: boolean = data[2]
let reason: string = data[3] || ''
if (ok) pubListeners[id]?.ok.forEach(cb => cb())
else pubListeners[id]?.failed.forEach(cb => cb(reason))
return
}
case 'NOTICE':
if (data.length !== 2) return // ignore empty or malformed NOTICE
let notice = data[1]
listeners.notice.forEach(cb => cb(notice))
return
}
this.ws.onmessage = ev => {
this.incomingMessageQueue.enqueue(ev.data as string)
if (!this.queueRunning) {
this.runQueue()
}
}
})
return this.connectionPromise
}
async function connect(): Promise<void> {
if (ws?.readyState && ws.readyState === 1) return // ws already open
await connectRelay()
}
async function trySend(params: [string, ...any]) {
let msg = JSON.stringify(params)
await untilOpen
ws.send(msg)
}
const sub = (
filters: Filter[],
{
skipVerification = false,
id = Math.random().toString().slice(2)
}: SubscriptionOptions = {}
): Sub => {
let subid = id
openSubs[subid] = {
id: subid,
filters,
skipVerification
private async runQueue() {
this.queueRunning = true
while (true) {
if (false === this.handleNext()) {
break
}
await yieldThread()
}
trySend(['REQ', subid, ...filters])
this.queueRunning = false
}
return {
sub: (newFilters, newOpts = {}) =>
sub(newFilters || filters, {
skipVerification: newOpts.skipVerification || skipVerification,
id: subid
}),
unsub: () => {
delete openSubs[subid]
delete subListeners[subid]
trySend(['CLOSE', subid])
},
on: (type: 'event' | 'eose', cb: any): void => {
subListeners[subid] = subListeners[subid] || {
event: [],
eose: []
private handleNext(): undefined | false {
const json = this.incomingMessageQueue.dequeue()
if (!json) {
return false
}
const subid = getSubscriptionId(json)
if (subid) {
const so = this.openSubs.get(subid as string)
if (!so) {
// this is an EVENT message, but for a subscription we don't have, so just stop here
return
}
// this will be called only when this message is a EVENT message for a subscription we have
// we do this before parsing the JSON to not have to do that for duplicate events
// since JSON parsing is slow
const id = getHex64(json, 'id')
const alreadyHave = so.alreadyHaveEvent?.(id)
// notify any interested client that the relay has this event
// (do this after alreadyHaveEvent() because the client may rely on this to answer that)
so.receivedEvent?.(this, id)
if (alreadyHave) {
// if we had already seen this event we can just stop here
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': {
const so = this.openSubs.get(data[1] as string) as Subscription
const event = data[2] as Event
if ((this.trusted || (validateEvent(event) && verifyEvent(event))) && matchFilters(so.filters, event)) {
so.onevent(event)
}
return
}
subListeners[subid][type].push(cb)
},
off: (type: 'event' | 'eose', cb: any): void => {
let listeners = subListeners[subid]
let idx = listeners[type].indexOf(cb)
if (idx >= 0) listeners[type].splice(idx, 1)
}
}
}
return {
url,
sub,
on: (
type: 'connect' | 'disconnect' | 'error' | 'notice',
cb: any
): void => {
listeners[type].push(cb)
if (type === 'connect' && ws?.readyState === 1) {
cb()
}
},
off: (
type: 'connect' | 'disconnect' | 'error' | 'notice',
cb: any
): void => {
let index = listeners[type].indexOf(cb)
if (index !== -1) listeners[type].splice(index, 1)
},
publish(event: Event): Pub {
if (!event.id) throw new Error(`event ${event} has no id`)
let id = event.id
var sent = false
var mustMonitor = false
trySend(['EVENT', event])
.then(() => {
sent = true
if (mustMonitor) {
startMonitoring()
mustMonitor = false
case 'COUNT': {
const id: string = data[1]
const payload = data[2] as { count: number }
const cr = this.openCountRequests.get(id) as CountResolver
if (cr) {
cr.resolve(payload.count)
this.openCountRequests.delete(id)
}
})
.catch(() => {})
const startMonitoring = () => {
let monitor = sub([{ids: [id]}], {
id: `monitor-${id.slice(0, 5)}`
})
let willUnsub = setTimeout(() => {
;(pubListeners[id]?.failed || []).forEach(cb =>
cb('event not seen after 5 seconds')
)
monitor.unsub()
}, 5000)
monitor.on('event', () => {
clearTimeout(willUnsub)
;(pubListeners[id]?.seen || []).forEach(cb => cb())
})
}
return {
on: (type: 'ok' | 'seen' | 'failed', cb: any) => {
pubListeners[id] = pubListeners[id] || {
ok: [],
seen: [],
failed: []
}
pubListeners[id][type].push(cb)
if (type === 'seen') {
if (sent) startMonitoring()
else mustMonitor = true
}
},
off: (type: 'ok' | 'seen' | 'failed', cb: any) => {
let listeners = pubListeners[id]
if (!listeners) return
let idx = listeners[type].indexOf(cb)
if (idx >= 0) listeners[type].splice(idx, 1)
return
}
case 'EOSE': {
const so = this.openSubs.get(data[1] as string)
if (!so) return
so.receivedEose()
return
}
case 'OK': {
const id: string = data[1]
const ok: boolean = data[2]
const reason: string = data[3]
const ep = this.openEventPublishes.get(id) as EventPublishResolver
if (ok) ep.resolve(reason)
else ep.reject(new Error(reason))
this.openEventPublishes.delete(id)
return
}
case 'CLOSED': {
const id: string = data[1]
const so = this.openSubs.get(id)
if (!so) return
so.closed = true
so.close(data[2] as string)
this.openSubs.delete(id)
return
}
case 'NOTICE':
this.onnotice(data[1] as string)
return
case 'AUTH': {
this.challenge = data[1] as string
return
}
}
},
connect,
close(): Promise<void> {
ws.close()
return new Promise(resolve => {
resolveClose = resolve
})
},
get status() {
return ws?.readyState ?? 3
} catch (err) {
return
}
}
public async send(message: string) {
if (!this.connectionPromise) throw new Error('sending on closed connection')
this.connectionPromise.then(() => {
this.ws?.send(message)
})
}
public async auth(signAuthEvent: (authEvent: EventTemplate) => Promise<void>) {
if (!this.challenge) throw new Error("can't perform auth, no challenge was received")
const evt = nip42.makeAuthEvent(this.url, this.challenge)
await signAuthEvent(evt)
this.send('["AUTH",' + JSON.stringify(evt) + ']')
}
public async publish(event: Event): Promise<string> {
const ret = new Promise<string>((resolve, reject) => {
this.openEventPublishes.set(event.id, { resolve, reject })
})
this.send('["EVENT",' + JSON.stringify(event) + ']')
return ret
}
public async count(filters: Filter[], params: { id?: string | null }): Promise<number> {
this.serial++
const id = params?.id || 'count:' + this.serial
const ret = new Promise<number>((resolve, reject) => {
this.openCountRequests.set(id, { resolve, reject })
})
this.send('["COUNT","' + id + '",' + JSON.stringify(filters) + ']')
return ret
}
public subscribe(filters: Filter[], params: Partial<SubscriptionParams>): Subscription {
const subscription = this.prepareSubscription(filters, params)
subscription.fire()
return subscription
}
public prepareSubscription(filters: Filter[], params: Partial<SubscriptionParams> & { id?: string }): Subscription {
this.serial++
const id = params.id || 'sub:' + this.serial
const subscription = new Subscription(this, id, filters, params)
this.openSubs.set(id, subscription)
return subscription
}
public close() {
this.closeAllSubscriptions('relay connection closed by us')
this._connected = false
this.ws?.close()
}
}
export class Subscription {
public readonly relay: Relay
public readonly id: string
public closed: boolean = false
public eosed: boolean = false
public filters: Filter[]
public alreadyHaveEvent: ((id: string) => boolean) | undefined
public receivedEvent: ((relay: Relay, id: string) => void) | undefined
public onevent: (evt: Event) => void
public oneose: (() => void) | undefined
public onclose: ((reason: string) => void) | undefined
public eoseTimeout: number
private eoseTimeoutHandle: ReturnType<typeof setTimeout> | undefined
constructor(relay: Relay, id: string, filters: Filter[], params: SubscriptionParams) {
this.relay = relay
this.filters = filters
this.id = id
this.alreadyHaveEvent = params.alreadyHaveEvent
this.receivedEvent = params.receivedEvent
this.eoseTimeout = params.eoseTimeout || relay.baseEoseTimeout
this.oneose = params.oneose
this.onclose = params.onclose
this.onevent =
params.onevent ||
(event => {
console.warn(
`onevent() callback not defined for subscription '${this.id}' in relay ${this.relay.url}. event received:`,
event,
)
})
}
public fire() {
this.relay.send('["REQ","' + this.id + '",' + JSON.stringify(this.filters).substring(1))
// only now we start counting the eoseTimeout
this.eoseTimeoutHandle = setTimeout(this.receivedEose.bind(this), this.eoseTimeout)
}
public receivedEose() {
if (this.eosed) return
clearTimeout(this.eoseTimeoutHandle)
this.eosed = true
this.oneose?.()
}
public close(reason: string = 'closed by caller') {
if (!this.closed) {
// if the connection was closed by the user calling .close() we will send a CLOSE message
// otherwise this._open will be already set to false so we will skip this
this.relay.send('["CLOSE",' + JSON.stringify(this.id) + ']')
this.closed = true
}
this.onclose?.(reason)
}
}
export type SubscriptionParams = {
onevent?: (evt: Event) => void
oneose?: () => void
onclose?: (reason: string) => void
alreadyHaveEvent?: (id: string) => boolean
receivedEvent?: (relay: Relay, id: string) => void
eoseTimeout?: number
}
export type CountResolver = {
resolve: (count: number) => void
reject: (err: Error) => void
}
export type EventPublishResolver = {
resolve: (reason: string) => void
reject: (err: Error) => void
}

15
test-helpers.ts Normal file
View File

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

View File

@@ -9,7 +9,10 @@
"skipLibCheck": true,
"esModuleInterop": true,
"emitDeclarationOnly": true,
"outDir": "dist",
"rootDir": "."
"outDir": "lib/types",
"resolveJsonModule": true,
"rootDir": ".",
"allowImportingTsExtensions": true,
"types": ["bun-types"]
}
}

265
utils.test.ts Normal file
View File

@@ -0,0 +1,265 @@
import { describe, test, expect } from 'bun:test'
import { buildEvent } from './test-helpers.ts'
import { Queue, insertEventIntoAscendingList, insertEventIntoDescendingList, binarySearch } from './utils.ts'
import type { Event } from './pure.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('enqueue a message into MessageQueue', () => {
test('enqueue into an empty queue', () => {
const queue = new Queue()
queue.enqueue('node1')
expect(queue.first!.value).toBe('node1')
})
test('enqueue into a non-empty queue', () => {
const queue = new Queue()
queue.enqueue('node1')
queue.enqueue('node3')
queue.enqueue('node2')
expect(queue.first!.value).toBe('node1')
expect(queue.last!.value).toBe('node2')
})
test('dequeue from an empty queue', () => {
const queue = new Queue()
const item1 = queue.dequeue()
expect(item1).toBe(null)
})
test('dequeue from a non-empty queue', () => {
const queue = new Queue()
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 Queue()
queue.enqueue('node1')
queue.enqueue('node3')
const item1 = queue.dequeue()
expect(item1).toBe('node1')
const item2 = queue.dequeue()
expect(item2).toBe('node3')
const item3 = queue.dequeue()
expect(item3).toBe(null)
})
})
test('binary search', () => {
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('e' < b ? -1 : 'e' === b ? 0 : 1))).toEqual([3, true])
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('x' < b ? -1 : 'x' === b ? 0 : 1))).toEqual([4, false])
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('c' < b ? -1 : 'c' === b ? 0 : 1))).toEqual([2, false])
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('a' < b ? -1 : 'a' === b ? 0 : 1))).toEqual([0, true])
expect(binarySearch(['a', 'b', 'd', 'e'], b => ('[' < b ? -1 : '[' === b ? 0 : 1))).toEqual([0, false])
})

114
utils.ts
View File

@@ -1,2 +1,116 @@
import type { Event } from './pure.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()
}
export function insertEventIntoDescendingList(sortedArray: Event[], event: Event) {
const [idx, found] = binarySearch(sortedArray, b => {
if (event.id === b.id) return 0
if (event.created_at === b.created_at) return -1
return b.created_at - event.created_at
})
if (!found) {
sortedArray.splice(idx, 0, event)
}
return sortedArray
}
export function insertEventIntoAscendingList(sortedArray: Event[], event: Event) {
const [idx, found] = binarySearch(sortedArray, b => {
if (event.id === b.id) return 0
if (event.created_at === b.created_at) return -1
return event.created_at - b.created_at
})
if (!found) {
sortedArray.splice(idx, 0, event)
}
return sortedArray
}
export function binarySearch<T>(arr: T[], compare: (b: T) => number): [number, boolean] {
let start = 0
let end = arr.length - 1
while (start <= end) {
const mid = Math.floor((start + end) / 2)
const cmp = compare(arr[mid])
if (cmp === 0) {
return [mid, true]
}
if (cmp < 0) {
end = mid - 1
} else {
start = mid + 1
}
}
return [start, false]
}
export class QueueNode<V> {
public value: V
public next: QueueNode<V> | null = null
public prev: QueueNode<V> | null = null
constructor(message: V) {
this.value = message
}
}
export class Queue<V> {
public first: QueueNode<V> | null
public last: QueueNode<V> | null
constructor() {
this.first = null
this.last = null
}
enqueue(value: V): boolean {
const newNode = new QueueNode(value)
if (!this.last) {
// list is empty
this.first = newNode
this.last = newNode
} else if (this.last === this.first) {
// list has a single element
this.last = newNode
this.last.prev = this.first
this.first.next = newNode
} else {
// list has elements, add as last
newNode.prev = this.last
this.last.next = newNode
this.last = newNode
}
return true
}
dequeue(): V | null {
if (!this.first) return null
if (this.first === this.last) {
const target = this.first
this.first = null
this.last = null
return target.value
}
const target = this.first
this.first = target.next
return target.value
}
}

38
wasm.ts Normal file
View File

@@ -0,0 +1,38 @@
import { bytesToHex } from '@noble/hashes/utils'
import { Nostr as NostrWasm } from 'nostr-wasm'
import { EventTemplate, Event, Nostr, VerifiedEvent, verifiedSymbol } from './core'
let nw: NostrWasm
export function setNostrWasm(x: NostrWasm) {
nw = x
}
class Wasm implements Nostr {
generateSecretKey(): Uint8Array {
return nw.generateSecretKey()
}
getPublicKey(secretKey: Uint8Array): string {
return bytesToHex(nw.getPublicKey(secretKey))
}
finalizeEvent(t: EventTemplate, secretKey: Uint8Array): VerifiedEvent {
nw.finalizeEvent(t as any, secretKey)
return t as VerifiedEvent
}
verifyEvent(event: Event): event is VerifiedEvent {
try {
nw.verifyEvent(event)
event[verifiedSymbol] = true
return true
} catch (err) {
return false
}
}
}
const i = new Wasm()
export const generateSecretKey = i.generateSecretKey
export const getPublicKey = i.getPublicKey
export const finalizeEvent = i.finalizeEvent
export const verifyEvent = i.verifyEvent
export * from './core.ts'